feat: all the control messages and basic sending of leave group

This commit is contained in:
0x330a 2023-11-27 17:24:57 +11:00
parent 56e9a42086
commit d02230cee3
No known key found for this signature in database
GPG Key ID: 267811D6E6A2698C
11 changed files with 156 additions and 45 deletions

View File

@ -1,9 +1,13 @@
package org.thoughtcrime.securesms.conversation.settings package org.thoughtcrime.securesms.conversation.settings
import android.content.Intent import android.content.Intent
import android.graphics.Typeface
import android.os.Bundle import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.text.set
import androidx.core.view.isVisible import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
@ -68,6 +72,7 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
binding.notificationSettings.setOnClickListener(this) binding.notificationSettings.setOnClickListener(this)
binding.editGroup.setOnClickListener(this) binding.editGroup.setOnClickListener(this)
binding.addAdmins.setOnClickListener(this) binding.addAdmins.setOnClickListener(this)
binding.leaveGroup.setOnClickListener(this)
binding.back.setOnClickListener(this) binding.back.setOnClickListener(this)
binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked -> binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked ->
viewModel.setAutoDownloadAttachments(isChecked) viewModel.setAutoDownloadAttachments(isChecked)
@ -86,7 +91,7 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
} }
// Setup group description (if group) // Setup group description (if group)
binding.conversationSubtitle.isVisible = recipient.isClosedGroupRecipient.apply { binding.conversationSubtitle.isVisible = recipient.isClosedGroupRecipient.apply {
binding.conversationSubtitle.text = "TODO: This is a test for group descriptions" binding.conversationSubtitle.text = viewModel.closedGroupInfo(recipient.address.serialize())?.description
} }
// Toggle group-specific settings // Toggle group-specific settings
@ -99,10 +104,12 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin() val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin()
with (binding) { with (binding) {
groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin
groupMembers.isVisible = areGroupOptionsVisible && !isUserGroupAdmin groupMembers.isVisible = !isUserGroupAdmin
adminControlsGroup.isVisible = isUserGroupAdmin adminControlsGroup.isVisible = isUserGroupAdmin
deleteGroup.isVisible = isUserGroupAdmin deleteGroup.isVisible = isUserGroupAdmin
clearMessages.isVisible = isUserGroupAdmin
clearMessagesDivider.root.isVisible = isUserGroupAdmin clearMessagesDivider.root.isVisible = isUserGroupAdmin
leaveGroupDivider.root.isVisible = isUserGroupAdmin
} }
// Set pinned state // Set pinned state
@ -161,6 +168,35 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
cancelButton() cancelButton()
} }
} }
v === binding.leaveGroup -> {
showSessionDialog {
title(R.string.conversation_settings_leave_group)
val name = viewModel.recipient!!.name!!
val text = getString(R.string.conversation_settings_leave_group_name)
val textWithArgs = getString(R.string.conversation_settings_leave_group_name, name)
// Searches for the start index of %1$s
val startIndex = """%1${"\\$"}s""".toRegex().find(text)?.range?.start
val endIndex = startIndex?.plus(name.length)
val styledText = if (startIndex == null || endIndex == null) {
textWithArgs
} else {
val boldName = SpannableStringBuilder(textWithArgs)
boldName[startIndex .. endIndex] = StyleSpan(Typeface.BOLD)
boldName
}
text(styledText)
destructiveButton(
R.string.conversation_settings_leave_group,
R.string.conversation_settings_leave_group
) {
viewModel.leaveGroup()
}
}
}
v === binding.editGroup -> { v === binding.editGroup -> {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
@ -61,6 +62,14 @@ class ConversationSettingsViewModel(
} }
} }
fun closedGroupInfo(address: String): GroupDisplayInfo? = storage.getClosedGroupDisplayInfo(address)
fun leaveGroup() {
viewModelScope.launch {
storage.leaveGroup(recipient!!.address.serialize())
}
}
// DI-related // DI-related
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {

View File

@ -6,6 +6,7 @@ import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import androidx.sqlite.db.transaction import androidx.sqlite.db.transaction
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
@ -22,6 +23,7 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
private const val VARIANT_IN_AND_PUBKEY_WHERE = "$VARIANT in (?) AND $PUBKEY = ?"
val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name val KEYS_VARIANT = SharedConfigMessage.Kind.ENCRYPTION_KEYS.name
val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name val INFO_VARIANT = SharedConfigMessage.Kind.CLOSED_GROUP_INFO.name
@ -39,6 +41,16 @@ class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(co
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
} }
fun deleteGroupConfigs(closedGroupId: SessionId) {
val db = writableDatabase
db.transaction {
val variants = arrayOf(KEYS_VARIANT, INFO_VARIANT, MEMBER_VARIANT)
db.delete(TABLE_NAME, VARIANT_IN_AND_PUBKEY_WHERE,
arrayOf(variants, closedGroupId.hexString())
)
}
}
fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) { fun storeGroupConfigs(publicKey: String, keysConfig: ByteArray, infoConfig: ByteArray, memberConfig: ByteArray, timestamp: Long) {
val db = writableDatabase val db = writableDatabase
db.transaction { db.transaction {

View File

@ -34,6 +34,7 @@ import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobDelegate
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.MessageSendJob
@ -87,6 +88,7 @@ import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
@ -185,7 +187,7 @@ open class Storage(
// these should be removed in the group leave / handling new configs // these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere") Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
} else if (address.isClosedGroup) { } else if (address.isClosedGroup) {
TODO("add the thread deleted checks for new closed groups") Log.w("Loki", "Thread delete called for closed group address, expecting to be handled elsewhere")
} }
} else { } else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
@ -1345,10 +1347,24 @@ open class Storage(
response.get() response.get()
val newConfigSync = ConfigurationSyncJob(destination) val newConfigSync = ConfigurationSyncJob(destination)
var exception: Exception? = null
val delegate = object: JobDelegate {
override fun handleJobSucceeded(job: Job, dispatcherName: String) {}
override fun handleJobFailed(job: Job, dispatcherName: String, error: Exception) { exception = error }
override fun handleJobFailedPermanently(
job: Job,
dispatcherName: String,
error: Exception
) { exception = error }
}
newConfigSync.delegate = delegate
runBlocking { runBlocking {
newConfigSync.execute("updating-members") newConfigSync.execute("updating-members")
} }
// rethrow failure
exception?.let { throw it }
configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray()) val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray())
@ -1366,8 +1382,9 @@ open class Storage(
.setAdminSignature(ByteString.copyFrom(signature)) .setAdminSignature(ByteString.copyFrom(signature))
) )
.build() .build()
) ).apply { this.sentTimestamp = timestamp }
MessageSender.send(updatedMessage, fromSerialized(groupSessionId)) MessageSender.send(updatedMessage, fromSerialized(groupSessionId))
insertGroupInfoChange(updatedMessage, sessionId)
infoConfig.free() infoConfig.free()
membersConfig.free() membersConfig.free()
keysConfig.free() keysConfig.free()
@ -1383,12 +1400,12 @@ open class Storage(
} }
override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) { override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) {
val sentTimestamp = message.sentTimestamp ?: return val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset
val senderPublicKey = message.sender ?: return val senderPublicKey = message.sender
val userPublicKey = getUserPublicKey()!! val userPublicKey = getUserPublicKey()!!
val updateData = UpdateMessageData.buildGroupUpdate(message)?.toJSON() ?: return val updateData = UpdateMessageData.buildGroupUpdate(message)?.toJSON() ?: return
if (senderPublicKey == userPublicKey) { if (senderPublicKey == null || senderPublicKey == userPublicKey) {
val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString()), false) val recipient = Recipient.from(context, fromSerialized(closedGroup.hexString()), false)
val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, closedGroup.hexString(), null, sentTimestamp, 0, true, null, listOf(), listOf()) val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, closedGroup.hexString(), null, sentTimestamp, 0, true, null, listOf(), listOf())
val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsDB = DatabaseComponent.get(context).mmsDatabase()
@ -1451,8 +1468,11 @@ open class Storage(
.setAdminSignature(ByteString.copyFrom(signature)) .setAdminSignature(ByteString.copyFrom(signature))
) )
.build() .build()
).apply { sentTimestamp = timestamp } ).apply {
sentTimestamp = timestamp
}
MessageSender.send(message, fromSerialized(groupSessionId)) MessageSender.send(message, fromSerialized(groupSessionId))
insertGroupInfoChange(message, closedGroupId)
} }
override fun handlePromoted(keyPair: KeyPair) { override fun handlePromoted(keyPair: KeyPair) {
@ -1480,6 +1500,27 @@ open class Storage(
keys.free() keys.free()
} }
override fun leaveGroup(groupSessionId: String) {
val closedGroupId = SessionId.from(groupSessionId)
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
.build()
)
try {
MessageSender.sendNonDurably(message, fromSerialized(groupSessionId), false).get()
pollerFactory.pollerFor(closedGroupId)?.stop()
// TODO: unsub from pushes
getThreadId(fromSerialized(groupSessionId))?.let { threadId ->
deleteConversation(threadId)
}
configFactory.removeGroup(closedGroupId)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} catch (e: Exception) {
Log.e("ClosedGroup", "Failed to send leave group message")
}
}
override fun setServerCapabilities(server: String, capabilities: List<String>) { override fun setServerCapabilities(server: String, capabilities: List<String>) {
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
} }

View File

@ -402,6 +402,12 @@ class ConfigFactory(
configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp) configDatabase.storeGroupConfigs(pubKey, groupKeys.dump(), groupInfo.dump(), groupMembers.dump(), timestamp)
} }
override fun removeGroup(closedGroupId: SessionId) {
val groups = userGroups ?: return
groups.eraseClosedGroup(closedGroupId.hexString())
configDatabase.deleteGroupConfigs(closedGroupId)
}
override fun scheduleUpdate(destination: Destination) { override fun scheduleUpdate(destination: Destination) {
// there's probably a better way to do this // there's probably a better way to do this
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)

View File

@ -1079,6 +1079,5 @@
<string name="media_overview_activity__clear_media">Clear All</string> <string name="media_overview_activity__clear_media">Clear All</string>
<string name="activity_create_closed_group_select_contacts">Select Contacts</string> <string name="activity_create_closed_group_select_contacts">Select Contacts</string>
<string name="activity_create_closed_group_add_account_or_ons">Add Account ID or ONS</string> <string name="activity_create_closed_group_add_account_or_ons">Add Account ID or ONS</string>
<string name="closed_group_update_control__single_joined"></string> <string name="conversation_settings_leave_group_name">Are you sure you want to leave %1$s?</string>
<string name="closed_group_update_control__two_joined"></string>
</resources> </resources>

View File

@ -171,6 +171,7 @@ interface StorageProtocol {
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId)
fun promoteMember(groupSessionId: String, promotions: Array<String>) fun promoteMember(groupSessionId: String, promotions: Array<String>)
fun handlePromoted(keyPair: KeyPair) fun handlePromoted(keyPair: KeyPair)
fun leaveGroup(groupSessionId: String)
// Groups // Groups
fun getAllGroups(includeInactive: Boolean): List<GroupRecord> fun getAllGroups(includeInactive: Boolean): List<GroupRecord>

View File

@ -10,6 +10,8 @@ class GroupUpdated(val inner: GroupUpdateMessage): ControlMessage() {
return true // TODO: add the validation here return true // TODO: add the validation here
} }
override val isSelfSendValid: Boolean = true
companion object { companion object {
fun fromProto(message: Content): GroupUpdated? = fun fromProto(message: Content): GroupUpdated? =
if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage()) if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage())

View File

@ -77,52 +77,54 @@ object UpdateMessageBuilder {
val number = updateData.sessionIds.size val number = updateData.sessionIds.size
if (number == 1) context.getString( if (number == 1) context.getString(
R.string.ConversationItem_group_member_added_single, R.string.ConversationItem_group_member_added_single,
getSenderName(updateData.sessionIds.first()) context.youOrSender(updateData.sessionIds.first())
) )
else if (number == 2) context.getString( else if (number == 2) context.getString(
R.string.ConversationItem_group_member_added_two, R.string.ConversationItem_group_member_added_two,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last()) context.youOrSender(updateData.sessionIds.last())
) )
else context.getString( else context.getString(
R.string.ConversationItem_group_member_added_multiple, R.string.ConversationItem_group_member_added_multiple,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1 updateData.sessionIds.size - 1
) )
} }
UpdateMessageData.MemberUpdateType.PROMOTED -> { UpdateMessageData.MemberUpdateType.PROMOTED -> {
val number = updateData.sessionIds.size when (updateData.sessionIds.size) {
if (number == 1) context.getString( 1 -> context.getString(
R.string.ConversationItem_group_member_promoted_single, R.string.ConversationItem_group_member_promoted_single,
getSenderName(updateData.sessionIds.first()) context.youOrSender(updateData.sessionIds.first())
) )
else if (number == 2) context.getString( 2 -> context.getString(
R.string.ConversationItem_group_member_promoted_two, R.string.ConversationItem_group_member_promoted_two,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last()) context.youOrSender(updateData.sessionIds.last())
) )
else context.getString( else -> context.getString(
R.string.ConversationItem_group_member_promoted_multiple, R.string.ConversationItem_group_member_promoted_multiple,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1 updateData.sessionIds.size - 1
) )
}
} }
UpdateMessageData.MemberUpdateType.REMOVED -> { UpdateMessageData.MemberUpdateType.REMOVED -> {
val number = updateData.sessionIds.size when (updateData.sessionIds.size) {
if (number == 1) context.getString( 1 -> context.getString(
R.string.ConversationItem_group_member_removed_single, R.string.ConversationItem_group_member_removed_single,
getSenderName(updateData.sessionIds.first()) context.youOrSender(updateData.sessionIds.first())
) )
else if (number == 2) context.getString( 2 -> context.getString(
R.string.ConversationItem_group_member_removed_two, R.string.ConversationItem_group_member_removed_two,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last()) context.youOrSender(updateData.sessionIds.last())
) )
else context.getString( else -> context.getString(
R.string.ConversationItem_group_member_removed_multiple, R.string.ConversationItem_group_member_removed_multiple,
getSenderName(updateData.sessionIds.first()), context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1 updateData.sessionIds.size - 1
) )
}
} }
null -> "" null -> ""
} }
@ -131,6 +133,8 @@ object UpdateMessageBuilder {
} }
} }
fun Context.youOrSender(sessionId: String) = if (storage.getUserPublicKey() == sessionId) getString(R.string.MessageRecord_you) else getSenderName(sessionId)
fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String { fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String {
if (!isOutgoing && senderId == null) return "" if (!isOutgoing && senderId == null) return ""
val senderName: String = if (!isOutgoing) { val senderName: String = if (!isOutgoing) {

View File

@ -35,6 +35,7 @@ interface ConfigFactoryProtocol {
groupInfo: GroupInfoConfig, groupInfo: GroupInfoConfig,
groupMembers: GroupMembersConfig groupMembers: GroupMembersConfig
) )
fun removeGroup(closedGroupId: SessionId)
fun scheduleUpdate(destination: Destination) fun scheduleUpdate(destination: Destination)
fun constructGroupKeysConfig( fun constructGroupKeysConfig(

View File

@ -1,4 +1,4 @@
package org.session.libsession.utilities package org.session.libsession.utilities
fun truncateIdForDisplay(id: String): String = fun truncateIdForDisplay(id: String): String =
id.takeIf { it.length > 8 }?.apply{ "${take(4)}${takeLast(4)}" } ?: id id.takeIf { it.length > 8 }?.run{ "${take(4)}${takeLast(4)}" } ?: id