From a34b1f74b343cff4bb3870599eab94501c5da050 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Tue, 6 Oct 2020 13:47:46 +1100 Subject: [PATCH 1/4] Fix SSK group leaving race condition --- .../loki/protocol/ClosedGroupsProtocol.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt index 8e5e00e695..9e4d8a4379 100644 --- a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt +++ b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt @@ -130,12 +130,18 @@ object ClosedGroupsProtocol { Log.d("Loki", "Can't remove self and others simultaneously.") return } - // Send the update to the group (don't include new ratchets as everyone should regenerate new ratchets individually) - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), - name, setOf(), membersAsData, adminsAsData) - val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind) - job.setContext(context) - job.onRun() // Run the job immediately + // Establish sessions if needed + establishSessionsWithMembersIfNeeded(context, members) + // Send the update to the existing members using established channels (don't include new ratchets as everyone should regenerate new ratchets individually) + for (member in oldMembers) { + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), + name, setOf(), membersAsData, adminsAsData) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) + job.setContext(context) + job.onRun() // Run the job immediately + } // Delete all ratchets (it's important that this happens * after * sending out the update) sskDatabase.removeAllClosedGroupRatchets(groupPublicKey) // Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and @@ -147,8 +153,6 @@ object ClosedGroupsProtocol { // Notify the PN server LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) } else { - // Establish sessions if needed - establishSessionsWithMembersIfNeeded(context, members) // Send closed group update messages to any new members using established channels for (member in newMembers) { @Suppress("NAME_SHADOWING") From 389b4b9768f9d279c81d3c936acf36fa73a65426 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Tue, 6 Oct 2020 14:10:57 +1100 Subject: [PATCH 2/4] Show a loader while the group is updating --- res/layout/activity_edit_closed_group.xml | 22 +- .../activities/EditClosedGroupActivity.kt | 16 +- .../loki/protocol/ClosedGroupsProtocol.kt | 222 +++++++++--------- 3 files changed, 151 insertions(+), 109 deletions(-) diff --git a/res/layout/activity_edit_closed_group.xml b/res/layout/activity_edit_closed_group.xml index 16ecefc339..b5e09eb35d 100644 --- a/res/layout/activity_edit_closed_group.xml +++ b/res/layout/activity_edit_closed_group.xml @@ -1,9 +1,9 @@ - + + + + + + \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt b/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt index 1ee9270e6b..3d36afcd67 100644 --- a/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt +++ b/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt @@ -13,17 +13,23 @@ import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources +import kotlinx.android.synthetic.main.activity_create_closed_group.* import kotlinx.android.synthetic.main.activity_create_closed_group.emptyStateContainer import kotlinx.android.synthetic.main.activity_create_closed_group.mainContentContainer import kotlinx.android.synthetic.main.activity_edit_closed_group.* +import kotlinx.android.synthetic.main.activity_edit_closed_group.loader import kotlinx.android.synthetic.main.activity_linked_devices.recyclerView import network.loki.messenger.R +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.loki.dialogs.ClosedGroupEditingOptionsBottomSheet import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocol +import org.thoughtcrime.securesms.loki.utilities.fadeIn +import org.thoughtcrime.securesms.loki.utilities.fadeOut import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.GroupUtil @@ -238,10 +244,16 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } if (isSSKBasedClosedGroup) { - ClosedGroupsProtocol.update(this, groupPublicKey!!, members.map { it.address.serialize() }, name) + loader.fadeIn() + ClosedGroupsProtocol.update(this, groupPublicKey!!, members.map { it.address.serialize() }, name).successUi { + loader.fadeOut() + finish() + }.failUi { exception -> + val message = if (exception is ClosedGroupsProtocol.Error) exception.description else "An error occurred" + Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + } } else { GroupManager.updateGroup(this, groupID, members, null, name, admins) } - finish() } } \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt index 9e4d8a4379..9050e39a80 100644 --- a/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt +++ b/src/org/thoughtcrime/securesms/loki/protocol/ClosedGroupsProtocol.kt @@ -25,6 +25,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup import org.whispersystems.signalservice.api.messages.SignalServiceGroup.GroupType import org.whispersystems.signalservice.internal.push.SignalServiceProtos import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext +import org.whispersystems.signalservice.loki.api.SnodeAPI import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupRatchet import org.whispersystems.signalservice.loki.protocol.closedgroups.ClosedGroupSenderKey import org.whispersystems.signalservice.loki.protocol.closedgroups.SharedSenderKeysImplementation @@ -38,7 +39,13 @@ import kotlin.jvm.Throws object ClosedGroupsProtocol { val isSharedSenderKeysEnabled = true val groupSizeLimit = 20 - + + sealed class Error(val description: String) : Exception() { + object NoThread : Error("Couldn't find a thread associated with the given group public key") + object NoPrivateKey : Error("Couldn't find a private key associated with the given group public key.") + object InvalidUpdate : Error("Invalid group update.") + } + public fun createClosedGroup(context: Context, name: String, members: Collection): Promise { val deferred = deferred() Thread { @@ -98,123 +105,128 @@ object ClosedGroupsProtocol { val name = group.title val oldMembers = group.members.map { it.serialize() }.toSet() val newMembers = oldMembers.minus(userPublicKey) - update(context, groupPublicKey, newMembers, name) + return update(context, groupPublicKey, newMembers, name).get() } - public fun update(context: Context, groupPublicKey: String, members: Collection, name: String) { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) - val sskDatabase = DatabaseFactory.getSSKDatabase(context) - val groupDB = DatabaseFactory.getGroupDatabase(context) - val groupID = doubleEncodeGroupID(groupPublicKey) - val group = groupDB.getGroup(groupID).orNull() - if (group == null) { - Log.d("Loki", "Can't update nonexistent closed group.") - return - } - val oldMembers = group.members.map { it.serialize() }.toSet() - val newMembers = members.minus(oldMembers) - val membersAsData = members.map { Hex.fromStringCondensed(it) } - val admins = group.admins.map { it.serialize() } - val adminsAsData = admins.map { Hex.fromStringCondensed(it) } - val groupPrivateKey = DatabaseFactory.getSSKDatabase(context).getClosedGroupPrivateKey(groupPublicKey) - if (groupPrivateKey == null) { - Log.d("Loki", "Couldn't get private key for closed group.") - return - } - val wasAnyUserRemoved = members.toSet().intersect(oldMembers) != oldMembers.toSet() - val removedMembers = oldMembers.minus(members) - val isUserLeaving = removedMembers.contains(userPublicKey) - var newSenderKeys = listOf() - if (wasAnyUserRemoved) { - if (isUserLeaving && removedMembers.count() != 1) { - Log.d("Loki", "Can't remove self and others simultaneously.") - return + public fun update(context: Context, groupPublicKey: String, members: Collection, name: String): Promise { + val deferred = deferred() + Thread { + val userPublicKey = TextSecurePreferences.getLocalNumber(context) + val sskDatabase = DatabaseFactory.getSSKDatabase(context) + val groupDB = DatabaseFactory.getGroupDatabase(context) + val groupID = doubleEncodeGroupID(groupPublicKey) + val group = groupDB.getGroup(groupID).orNull() + if (group == null) { + Log.d("Loki", "Can't update nonexistent closed group.") + return@Thread deferred.reject(Error.NoThread) } - // Establish sessions if needed - establishSessionsWithMembersIfNeeded(context, members) - // Send the update to the existing members using established channels (don't include new ratchets as everyone should regenerate new ratchets individually) - for (member in oldMembers) { - @Suppress("NAME_SHADOWING") - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), - name, setOf(), membersAsData, adminsAsData) - @Suppress("NAME_SHADOWING") - val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) - job.setContext(context) - job.onRun() // Run the job immediately + val oldMembers = group.members.map { it.serialize() }.toSet() + val newMembers = members.minus(oldMembers) + val membersAsData = members.map { Hex.fromStringCondensed(it) } + val admins = group.admins.map { it.serialize() } + val adminsAsData = admins.map { Hex.fromStringCondensed(it) } + val groupPrivateKey = DatabaseFactory.getSSKDatabase(context).getClosedGroupPrivateKey(groupPublicKey) + if (groupPrivateKey == null) { + Log.d("Loki", "Couldn't get private key for closed group.") + return@Thread deferred.reject(Error.NoPrivateKey) } - // Delete all ratchets (it's important that this happens * after * sending out the update) - sskDatabase.removeAllClosedGroupRatchets(groupPublicKey) - // Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and - // send it out to all members (minus the removed ones) using established channels. - if (isUserLeaving) { - sskDatabase.removeClosedGroupPrivateKey(groupPublicKey) - groupDB.setActive(groupID, false) - groupDB.remove(groupID, Address.fromSerialized(userPublicKey)) - // Notify the PN server - LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) - } else { - // Send closed group update messages to any new members using established channels + val wasAnyUserRemoved = members.toSet().intersect(oldMembers) != oldMembers.toSet() + val removedMembers = oldMembers.minus(members) + val isUserLeaving = removedMembers.contains(userPublicKey) + var newSenderKeys = listOf() + if (wasAnyUserRemoved) { + if (isUserLeaving && removedMembers.count() != 1) { + Log.d("Loki", "Can't remove self and others simultaneously.") + return@Thread deferred.reject(Error.InvalidUpdate) + } + // Establish sessions if needed + establishSessionsWithMembersIfNeeded(context, members) + // Send the update to the existing members using established channels (don't include new ratchets as everyone should regenerate new ratchets individually) + for (member in oldMembers) { + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), + name, setOf(), membersAsData, adminsAsData) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) + job.setContext(context) + job.onRun() // Run the job immediately + } + // Delete all ratchets (it's important that this happens * after * sending out the update) + sskDatabase.removeAllClosedGroupRatchets(groupPublicKey) + // Remove the group from the user's set of public keys to poll for if the user is leaving. Otherwise generate a new ratchet and + // send it out to all members (minus the removed ones) using established channels. + if (isUserLeaving) { + sskDatabase.removeClosedGroupPrivateKey(groupPublicKey) + groupDB.setActive(groupID, false) + groupDB.remove(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + LokiPushNotificationManager.performOperation(context, ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) + } else { + // Send closed group update messages to any new members using established channels + for (member in newMembers) { + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, + Hex.fromStringCondensed(groupPrivateKey), listOf(), membersAsData, adminsAsData) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) + ApplicationContext.getInstance(context).jobManager.add(job) + } + // Send out the user's new ratchet to all members (minus the removed ones) using established channels + val userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, userPublicKey) + val userSenderKey = ClosedGroupSenderKey(Hex.fromStringCondensed(userRatchet.chainKey), userRatchet.keyIndex, Hex.fromStringCondensed(userPublicKey)) + for (member in members) { + if (member == userPublicKey) { continue } + @Suppress("NAME_SHADOWING") + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.SenderKey(Hex.fromStringCondensed(groupPublicKey), userSenderKey) + @Suppress("NAME_SHADOWING") + val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) + ApplicationContext.getInstance(context).jobManager.add(job) + } + } + } else if (newMembers.isNotEmpty()) { + // Generate ratchets for any new members + newSenderKeys = newMembers.map { publicKey -> + val ratchet = SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, publicKey) + ClosedGroupSenderKey(Hex.fromStringCondensed(ratchet.chainKey), ratchet.keyIndex, Hex.fromStringCondensed(publicKey)) + } + // Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group) + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name, + newSenderKeys, membersAsData, adminsAsData) + val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind) + ApplicationContext.getInstance(context).jobManager.add(job) + // Establish sessions if needed + establishSessionsWithMembersIfNeeded(context, newMembers) + // Send closed group update messages to the new members using established channels + var allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey); + allSenderKeys = allSenderKeys.union(newSenderKeys) for (member in newMembers) { @Suppress("NAME_SHADOWING") val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, - Hex.fromStringCondensed(groupPrivateKey), listOf(), membersAsData, adminsAsData) + Hex.fromStringCondensed(groupPrivateKey), allSenderKeys, membersAsData, adminsAsData) @Suppress("NAME_SHADOWING") val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) ApplicationContext.getInstance(context).jobManager.add(job) } - // Send out the user's new ratchet to all members (minus the removed ones) using established channels - val userRatchet = SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, userPublicKey) - val userSenderKey = ClosedGroupSenderKey(Hex.fromStringCondensed(userRatchet.chainKey), userRatchet.keyIndex, Hex.fromStringCondensed(userPublicKey)) - for (member in members) { - if (member == userPublicKey) { continue } - @Suppress("NAME_SHADOWING") - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.SenderKey(Hex.fromStringCondensed(groupPublicKey), userSenderKey) - @Suppress("NAME_SHADOWING") - val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) - ApplicationContext.getInstance(context).jobManager.add(job) - } - } - } else if (newMembers.isNotEmpty()) { - // Generate ratchets for any new members - newSenderKeys = newMembers.map { publicKey -> - val ratchet = SharedSenderKeysImplementation.shared.generateRatchet(groupPublicKey, publicKey) - ClosedGroupSenderKey(Hex.fromStringCondensed(ratchet.chainKey), ratchet.keyIndex, Hex.fromStringCondensed(publicKey)) - } - // Send a closed group update message to the existing members with the new members' ratchets (this message is aimed at the group) - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name, - newSenderKeys, membersAsData, adminsAsData) - val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind) - ApplicationContext.getInstance(context).jobManager.add(job) - // Establish sessions if needed - establishSessionsWithMembersIfNeeded(context, newMembers) - // Send closed group update messages to the new members using established channels - var allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey); - allSenderKeys = allSenderKeys.union(newSenderKeys) - for (member in newMembers) { - @Suppress("NAME_SHADOWING") - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.New(Hex.fromStringCondensed(groupPublicKey), name, - Hex.fromStringCondensed(groupPrivateKey), allSenderKeys, membersAsData, adminsAsData) - @Suppress("NAME_SHADOWING") - val job = ClosedGroupUpdateMessageSendJob(member, closedGroupUpdateKind) + } else { + val allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey); + val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name, + allSenderKeys, membersAsData, adminsAsData) + val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind) ApplicationContext.getInstance(context).jobManager.add(job) } - } else { - val allSenderKeys = sskDatabase.getAllClosedGroupSenderKeys(groupPublicKey); - val closedGroupUpdateKind = ClosedGroupUpdateMessageSendJob.Kind.Info(Hex.fromStringCondensed(groupPublicKey), name, - allSenderKeys, membersAsData, adminsAsData) - val job = ClosedGroupUpdateMessageSendJob(groupPublicKey, closedGroupUpdateKind) - ApplicationContext.getInstance(context).jobManager.add(job) - } - // Update the group - groupDB.updateTitle(groupID, name) - if (!isUserLeaving) { - // The call below sets isActive to true, so if the user is leaving we have to use groupDB.remove(...) instead - groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) }) - } - // Notify the user - val infoType = if (isUserLeaving) GroupContext.Type.QUIT else GroupContext.Type.UPDATE - val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false)) - insertOutgoingInfoMessage(context, groupID, infoType, name, members, admins, threadID) + // Update the group + groupDB.updateTitle(groupID, name) + if (!isUserLeaving) { + // The call below sets isActive to true, so if the user is leaving we have to use groupDB.remove(...) instead + groupDB.updateMembers(groupID, members.map { Address.fromSerialized(it) }) + } + // Notify the user + val infoType = if (isUserLeaving) GroupContext.Type.QUIT else GroupContext.Type.UPDATE + val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.from(context, Address.fromSerialized(groupID), false)) + insertOutgoingInfoMessage(context, groupID, infoType, name, members, admins, threadID) + deferred.resolve(Unit) + }.start() + return deferred.promise } @JvmStatic From 6d724fe349edcdcdf57c087ede87fdafd2cd3226 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Tue, 6 Oct 2020 14:15:37 +1100 Subject: [PATCH 3/4] Disable the apply button while the group is being updated --- .../loki/activities/EditClosedGroupActivity.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt b/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt index 3d36afcd67..925da4cdbb 100644 --- a/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt +++ b/src/org/thoughtcrime/securesms/loki/activities/EditClosedGroupActivity.kt @@ -42,6 +42,8 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { private val originalMembers = HashSet() private val members = HashSet() private var hasNameChanged = false + private var isLoading = false + set(newValue) { field = newValue; invalidateOptionsMenu() } private lateinit var groupID: String private lateinit var originalName: String @@ -121,7 +123,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_edit_closed_group, menu) - return members.isNotEmpty() + return members.isNotEmpty() && !isLoading } // endregion @@ -171,8 +173,8 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { // region Interaction override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { - R.id.action_apply -> commitChanges() + when (item.itemId) { + R.id.action_apply -> if (!isLoading) { commitChanges() } } return super.onOptionsItemSelected(item) } @@ -244,13 +246,16 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } if (isSSKBasedClosedGroup) { + isLoading = true loader.fadeIn() ClosedGroupsProtocol.update(this, groupPublicKey!!, members.map { it.address.serialize() }, name).successUi { loader.fadeOut() + isLoading = false finish() }.failUi { exception -> val message = if (exception is ClosedGroupsProtocol.Error) exception.description else "An error occurred" Toast.makeText(this@EditClosedGroupActivity, message, Toast.LENGTH_LONG).show() + isLoading = false } } else { GroupManager.updateGroup(this, groupID, members, null, name, admins) From 0c669548f333f2f49dce8385ebeb0a80de03ce58 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Tue, 6 Oct 2020 14:19:52 +1100 Subject: [PATCH 4/4] Disable the done button while the group is being created --- res/layout/activity_create_closed_group.xml | 2 +- res/layout/activity_home.xml | 2 +- .../activities/CreateClosedGroupActivity.kt | 19 ++++++++----------- .../securesms/loki/activities/HomeActivity.kt | 2 +- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/res/layout/activity_create_closed_group.xml b/res/layout/activity_create_closed_group.xml index 78095701e9..382380283b 100644 --- a/res/layout/activity_create_closed_group.xml +++ b/res/layout/activity_create_closed_group.xml @@ -57,7 +57,7 @@