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
import android.content.Intent
import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.View
import androidx.activity.viewModels
import androidx.core.text.set
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
@ -68,6 +72,7 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
binding.notificationSettings.setOnClickListener(this)
binding.editGroup.setOnClickListener(this)
binding.addAdmins.setOnClickListener(this)
binding.leaveGroup.setOnClickListener(this)
binding.back.setOnClickListener(this)
binding.autoDownloadMediaSwitch.setOnCheckedChangeListener { _, isChecked ->
viewModel.setAutoDownloadAttachments(isChecked)
@ -86,7 +91,7 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
}
// Setup group description (if group)
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
@ -99,10 +104,12 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
val isUserGroupAdmin = areGroupOptionsVisible && viewModel.isUserGroupAdmin()
with (binding) {
groupMembersDivider.root.isVisible = areGroupOptionsVisible && !isUserGroupAdmin
groupMembers.isVisible = areGroupOptionsVisible && !isUserGroupAdmin
groupMembers.isVisible = !isUserGroupAdmin
adminControlsGroup.isVisible = isUserGroupAdmin
deleteGroup.isVisible = isUserGroupAdmin
clearMessages.isVisible = isUserGroupAdmin
clearMessagesDivider.root.isVisible = isUserGroupAdmin
leaveGroupDivider.root.isVisible = isUserGroupAdmin
}
// Set pinned state
@ -161,6 +168,35 @@ class ConversationSettingsActivity: PassphraseRequiredActionBarActivity(), View.
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 -> {
val recipient = viewModel.recipient ?: return

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import org.session.libsession.database.StorageProtocol
import org.session.libsession.utilities.Address
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
@dagger.assisted.AssistedFactory
interface AssistedFactory {

View File

@ -6,6 +6,7 @@ import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull
import androidx.sqlite.db.transaction
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.SessionId
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
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));"
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 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))
}
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) {
val db = writableDatabase
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.InviteContactsJob
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.MessageReceiveJob
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.GroupUpdateInviteResponseMessage
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.Hex
import org.session.libsignal.utilities.IdPrefix
@ -185,7 +187,7 @@ open class Storage(
// 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")
} 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 {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
@ -1345,10 +1347,24 @@ open class Storage(
response.get()
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 {
newConfigSync.execute("updating-members")
}
// rethrow failure
exception?.let { throw it }
configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray())
@ -1366,8 +1382,9 @@ open class Storage(
.setAdminSignature(ByteString.copyFrom(signature))
)
.build()
)
).apply { this.sentTimestamp = timestamp }
MessageSender.send(updatedMessage, fromSerialized(groupSessionId))
insertGroupInfoChange(updatedMessage, sessionId)
infoConfig.free()
membersConfig.free()
keysConfig.free()
@ -1383,12 +1400,12 @@ open class Storage(
}
override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId) {
val sentTimestamp = message.sentTimestamp ?: return
val senderPublicKey = message.sender ?: return
val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset
val senderPublicKey = message.sender
val userPublicKey = getUserPublicKey()!!
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 infoMessage = OutgoingGroupMediaMessage(recipient, updateData, closedGroup.hexString(), null, sentTimestamp, 0, true, null, listOf(), listOf())
val mmsDB = DatabaseComponent.get(context).mmsDatabase()
@ -1451,8 +1468,11 @@ open class Storage(
.setAdminSignature(ByteString.copyFrom(signature))
)
.build()
).apply { sentTimestamp = timestamp }
).apply {
sentTimestamp = timestamp
}
MessageSender.send(message, fromSerialized(groupSessionId))
insertGroupInfoChange(message, closedGroupId)
}
override fun handlePromoted(keyPair: KeyPair) {
@ -1480,6 +1500,27 @@ open class Storage(
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>) {
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)
}
override fun removeGroup(closedGroupId: SessionId) {
val groups = userGroups ?: return
groups.eraseClosedGroup(closedGroupId.hexString())
configDatabase.deleteGroupConfigs(closedGroupId)
}
override fun scheduleUpdate(destination: Destination) {
// there's probably a better way to do this
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(destination)

View File

@ -1079,6 +1079,5 @@
<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_add_account_or_ons">Add Account ID or ONS</string>
<string name="closed_group_update_control__single_joined"></string>
<string name="closed_group_update_control__two_joined"></string>
<string name="conversation_settings_leave_group_name">Are you sure you want to leave %1$s?</string>
</resources>

View File

@ -171,6 +171,7 @@ interface StorageProtocol {
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: SessionId)
fun promoteMember(groupSessionId: String, promotions: Array<String>)
fun handlePromoted(keyPair: KeyPair)
fun leaveGroup(groupSessionId: String)
// Groups
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
}
override val isSelfSendValid: Boolean = true
companion object {
fun fromProto(message: Content): GroupUpdated? =
if (message.hasDataMessage() && message.dataMessage.hasGroupUpdateMessage())

View File

@ -77,52 +77,54 @@ object UpdateMessageBuilder {
val number = updateData.sessionIds.size
if (number == 1) context.getString(
R.string.ConversationItem_group_member_added_single,
getSenderName(updateData.sessionIds.first())
context.youOrSender(updateData.sessionIds.first())
)
else if (number == 2) context.getString(
R.string.ConversationItem_group_member_added_two,
getSenderName(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last())
context.youOrSender(updateData.sessionIds.first()),
context.youOrSender(updateData.sessionIds.last())
)
else context.getString(
R.string.ConversationItem_group_member_added_multiple,
getSenderName(updateData.sessionIds.first()),
context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1
)
}
UpdateMessageData.MemberUpdateType.PROMOTED -> {
val number = updateData.sessionIds.size
if (number == 1) context.getString(
R.string.ConversationItem_group_member_promoted_single,
getSenderName(updateData.sessionIds.first())
)
else if (number == 2) context.getString(
R.string.ConversationItem_group_member_promoted_two,
getSenderName(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last())
)
else context.getString(
R.string.ConversationItem_group_member_promoted_multiple,
getSenderName(updateData.sessionIds.first()),
updateData.sessionIds.size - 1
)
when (updateData.sessionIds.size) {
1 -> context.getString(
R.string.ConversationItem_group_member_promoted_single,
context.youOrSender(updateData.sessionIds.first())
)
2 -> context.getString(
R.string.ConversationItem_group_member_promoted_two,
context.youOrSender(updateData.sessionIds.first()),
context.youOrSender(updateData.sessionIds.last())
)
else -> context.getString(
R.string.ConversationItem_group_member_promoted_multiple,
context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1
)
}
}
UpdateMessageData.MemberUpdateType.REMOVED -> {
val number = updateData.sessionIds.size
if (number == 1) context.getString(
R.string.ConversationItem_group_member_removed_single,
getSenderName(updateData.sessionIds.first())
)
else if (number == 2) context.getString(
R.string.ConversationItem_group_member_removed_two,
getSenderName(updateData.sessionIds.first()),
getSenderName(updateData.sessionIds.last())
)
else context.getString(
R.string.ConversationItem_group_member_removed_multiple,
getSenderName(updateData.sessionIds.first()),
updateData.sessionIds.size - 1
)
when (updateData.sessionIds.size) {
1 -> context.getString(
R.string.ConversationItem_group_member_removed_single,
context.youOrSender(updateData.sessionIds.first())
)
2 -> context.getString(
R.string.ConversationItem_group_member_removed_two,
context.youOrSender(updateData.sessionIds.first()),
context.youOrSender(updateData.sessionIds.last())
)
else -> context.getString(
R.string.ConversationItem_group_member_removed_multiple,
context.youOrSender(updateData.sessionIds.first()),
updateData.sessionIds.size - 1
)
}
}
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 {
if (!isOutgoing && senderId == null) return ""
val senderName: String = if (!isOutgoing) {

View File

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

View File

@ -1,4 +1,4 @@
package org.session.libsession.utilities
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