feat: add info name and description updates

This commit is contained in:
0x330a 2023-12-04 17:55:44 +11:00
parent 4b6a7c145e
commit ae6307c2e2
No known key found for this signature in database
GPG key ID: 267811D6E6A2698C
5 changed files with 281 additions and 102 deletions

View file

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import com.google.protobuf.ByteString
import kotlinx.coroutines.runBlocking
import network.loki.messenger.libsession_util.Config
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
@ -35,7 +34,6 @@ import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.mess
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
@ -72,6 +70,7 @@ import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.RawResponse
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeAPI.signingKeyCallback
import org.session.libsession.snode.SnodeMessage
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
@ -87,6 +86,7 @@ import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
@ -1323,8 +1323,6 @@ open class Storage(
keysConfig.rekey(infoConfig, membersConfig)
val sentTimestamp = SnodeAPI.nowWithOffset
// build unrevocation, in case of re-adding members
val unrevocation = SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest(
groupSessionId,
@ -1333,52 +1331,35 @@ open class Storage(
) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
// Build and store the key update in group swarm
val message = SnodeMessage(
groupSessionId,
Base64.encodeBytes(keysConfig.pendingConfig()!!), // should not be null from checking has pending
SnodeMessage.CONFIG_TTL,
sentTimestamp
)
val authenticatedBatch = SnodeAPI.buildAuthenticatedStoreBatchInfo(
keysConfig.namespace(),
message,
adminKey
val toDelete = mutableListOf<String>()
val signCallback = signingKeyCallback(adminKey)
val keyMessage = keysConfig.messageInformation(groupSessionId, adminKey)
val infoMessage = infoConfig.messageInformation(toDelete, groupSessionId, adminKey)
val membersMessage = membersConfig.messageInformation(toDelete, groupSessionId, adminKey)
val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
groupSessionId,
toDelete,
signCallback
)
val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch)
val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
SnodeAPI.getRawBatchResponse(
snode,
groupSessionId,
listOf(unrevocation, authenticatedBatch),
stores + unrevocation + delete,
sequence = true
)
}
val destination = Destination.ClosedGroup(groupSessionId)
try {
response.get()
// todo: error handling here
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())
@ -1388,7 +1369,7 @@ open class Storage(
val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.ADDED.name}$timestamp"
val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
val updatedMessage = GroupUpdated(
DataMessage.GroupUpdateMessage.newBuilder()
GroupUpdateMessage.newBuilder()
.setMemberChangeMessage(
GroupUpdateMemberChangeMessage.newBuilder()
.addAllMemberSessionIds(filteredMembers)
@ -1512,24 +1493,29 @@ open class Storage(
removedMembers.map { keys.getSubAccountToken(SessionId.from(it)) }.toTypedArray()
) ?: return Log.e("ClosedGroup", "Failed to build revocation update")
// Build and store the key update in group swarm
val storeKeyMessage = SnodeMessage(
keys.rekey(info, members)
val toDelete = mutableListOf<String>()
val keyMessage = keys.messageInformation(groupSessionId, adminKey)
val infoMessage = info.messageInformation(toDelete, groupSessionId, adminKey)
val membersMessage = members.messageInformation(toDelete, groupSessionId, adminKey)
val signCallback = signingKeyCallback(adminKey)
val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
groupSessionId,
Base64.encodeBytes(keys.pendingConfig()!!), // should not be null from checking has pending
SnodeMessage.CONFIG_TTL,
SnodeAPI.nowWithOffset
)
val authenticatedBatch = SnodeAPI.buildAuthenticatedStoreBatchInfo(
keys.namespace(),
storeKeyMessage,
adminKey
toDelete,
signCallback
)
val stores = listOf(keyMessage, infoMessage, membersMessage).map(ConfigurationSyncJob.ConfigMessageInformation::batch)
val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode ->
SnodeAPI.getRawBatchResponse(
snode,
groupSessionId,
listOf(revocation, authenticatedBatch),
stores + revocation + delete,
sequence = true
)
}
@ -1542,31 +1528,11 @@ open class Storage(
throw Exception("Response wasn't successful for revoke and key update: ${results["body"] as? String}")
}
val newConfigSync = ConfigurationSyncJob(Destination.ClosedGroup(groupSessionId))
configFactory.saveGroupConfigs(keys, info, members)
info.free()
members.free()
keys.free()
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 }
val timestamp = SnodeAPI.nowWithOffset
val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp"
val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
@ -1624,7 +1590,7 @@ open class Storage(
if (closedGroup.hasAdminKey()) {
// re-key and do a new config removing the previous member
val adminKey = closedGroup.adminKey
val signCallback = SnodeAPI.signingKeyCallback(adminKey)
val signCallback = signingKeyCallback(adminKey)
val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
@ -1665,7 +1631,7 @@ open class Storage(
val messageToSign = "MEMBER_CHANGE${GroupUpdateMemberChangeMessage.Type.REMOVED.name}$timestamp"
val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
val updatedMessage = GroupUpdated(
DataMessage.GroupUpdateMessage.newBuilder()
GroupUpdateMessage.newBuilder()
.setMemberChangeMessage(
GroupUpdateMemberChangeMessage.newBuilder()
.addAllMemberSessionIds(listOf(message.sender!!))
@ -1713,6 +1679,43 @@ open class Storage(
}
}
override fun setName(groupSessionId: String, newName: String) {
val closedGroupId = SessionId.from(groupSessionId)
val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
if (adminKey.isEmpty()) {
return Log.e("ClosedGroup", "No admin key for group")
}
val info = configFactory.getGroupInfoConfig(closedGroupId) ?: return
val members = configFactory.getGroupMemberConfig(closedGroupId) ?: return
val keys = configFactory.getGroupKeysConfig(closedGroupId, info, members, free = false) ?: return
info.setName(newName)
configFactory.saveGroupConfigs(keys, info, members)
info.free()
members.free()
keys.free()
val groupDestination = Destination.ClosedGroup(groupSessionId)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
val timestamp = SnodeAPI.nowWithOffset
val messageToSign = "INFO_CHANGE${GroupUpdateInfoChangeMessage.Type.NAME.name}$timestamp"
val signature = SodiumUtilities.sign(messageToSign.toByteArray(), adminKey)
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setInfoChangeMessage(
GroupUpdateInfoChangeMessage.newBuilder()
.setUpdatedName(newName)
.setType(GroupUpdateInfoChangeMessage.Type.NAME)
.setAdminSignature(ByteString.copyFrom(signature))
)
.build()
).apply {
sentTimestamp = timestamp
}
MessageSender.send(message, fromSerialized(groupSessionId))
insertGroupInfoChange(message, closedGroupId)
}
override fun setServerCapabilities(server: String, capabilities: List<String>) {
return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities)
}

View file

@ -5,6 +5,8 @@ import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -17,6 +19,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
@ -24,11 +27,15 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
@ -37,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
@ -49,6 +57,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.NavResult
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import com.ramcosta.composedestinations.spec.DestinationStyle
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -60,9 +69,12 @@ import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.JobQueue
import org.thoughtcrime.securesms.groups.ContactList
import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupInviteScreenDestination
import org.thoughtcrime.securesms.groups.destinations.EditClosedGroupNameScreenDestination
import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
import org.thoughtcrime.securesms.ui.LocalExtraColors
import org.thoughtcrime.securesms.ui.NavigationBar
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
@EditGroupNavGraph(start = true)
@Composable
@ -70,6 +82,7 @@ import org.thoughtcrime.securesms.ui.PreviewTheme
fun EditClosedGroupScreen(
navigator: DestinationsNavigator,
resultSelectContact: ResultRecipient<EditClosedGroupInviteScreenDestination, ContactList>,
resultEditName: ResultRecipient<EditClosedGroupNameScreenDestination, String>,
viewModel: EditGroupViewModel,
onFinish: () -> Unit
) {
@ -84,6 +97,12 @@ fun EditClosedGroupScreen(
}
}
resultEditName.onNavResult { navResult ->
if (navResult is NavResult.Value) {
eventSink(EditGroupEvent.ChangeName(navResult.value))
}
}
EditGroupView(
onBack = {
onFinish()
@ -100,10 +119,125 @@ fun EditClosedGroupScreen(
onRemove = { contact ->
eventSink(EditGroupEvent.RemoveContact(contact))
},
onEditName = {
navigator.navigate(EditClosedGroupNameScreenDestination)
},
viewState = viewState
)
}
@EditGroupNavGraph
@Composable
@Destination(style = DestinationStyle.Dialog::class)
fun EditClosedGroupNameScreen(
resultNavigator: ResultBackNavigator<String>,
viewModel: EditGroupViewModel
) {
EditClosedGroupView { name ->
if (name.isEmpty()) {
resultNavigator.navigateBack()
} else {
resultNavigator.navigateBack(name)
}
}
}
@Composable
fun EditClosedGroupView(navigateBack: (String) -> Unit) {
var newName by remember {
mutableStateOf("")
}
var newDescription by remember {
mutableStateOf("")
}
Box(
Modifier
.fillMaxWidth()
.shadow(8.dp)
.background(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(id = R.string.dialog_edit_group_information_title),
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(id = R.string.dialog_edit_group_information_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
maxLines = 1,
singleLine = true,
placeholder = {
Text(
text = stringResource(id = R.string.dialog_edit_group_information_enter_group_name)
)
}
)
OutlinedTextField(
value = newDescription,
onValueChange = { newDescription = it },
modifier = Modifier
.fillMaxWidth(),
minLines = 2,
maxLines = 2,
placeholder = {
Text(
text = stringResource(id = R.string.dialog_edit_group_information_enter_group_description)
)
}
)
Row(modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.save),
modifier = Modifier
.padding(16.dp)
.weight(1f)
.clickable {
navigateBack(newName)
},
textAlign = TextAlign.Center,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.cancel),
modifier = Modifier
.padding(16.dp)
.weight(1f)
.clickable {
navigateBack("")
},
textAlign = TextAlign.Center,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = LocalExtraColors.current.destructive
)
}
}
}
}
@EditGroupNavGraph
@Composable
@Destination
@ -198,6 +332,9 @@ class EditGroupViewModel @AssistedInject constructor(
is EditGroupEvent.RemoveContact -> {
storage.removeMember(groupSessionId, arrayOf(event.contactSessionId))
}
is EditGroupEvent.ChangeName -> {
storage.setName(groupSessionId, event.newName)
}
}
}
}
@ -254,6 +391,7 @@ fun EditGroupView(
onReinvite: (String)->Unit,
onPromote: (String)->Unit,
onRemove: (String)->Unit,
onEditName: ()->Unit,
viewState: EditGroupViewState,
) {
val scaffoldState = rememberScaffoldState()
@ -270,15 +408,31 @@ fun EditGroupView(
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
// Group name title
Text(
text = viewState.groupName,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
horizontalArrangement = Arrangement.Center
) {
Text(
text = viewState.groupName,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
if (viewState.admin) {
Icon(
painterResource(R.drawable.ic_baseline_edit_24),
null,
modifier = Modifier
.padding(8.dp)
.size(16.dp)
.clip(CircleShape)
.align(CenterVertically)
.clickable { onEditName() }
)
}
}
// Description
// Invite
@ -454,6 +608,7 @@ sealed class EditGroupEvent {
data class ReInviteContact(val contactSessionId: String): EditGroupEvent()
data class PromoteContact(val contactSessionId: String): EditGroupEvent()
data class RemoveContact(val contactSessionId: String): EditGroupEvent()
data class ChangeName(val newName: String): EditGroupEvent()
}
data class EditGroupInviteViewState(
@ -461,43 +616,57 @@ data class EditGroupInviteViewState(
val allContacts: Set<Contact>
)
@Preview
@Composable
fun PreviewDialogChange(@PreviewParameter(ThemeResPreviewParameterProvider::class) styleRes: Int) {
PreviewTheme(themeResId = styleRes) {
EditClosedGroupView {
}
}
}
@Preview
@Composable
fun PreviewList() {
val oneMember = MemberViewModel(
"Test User",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
MemberState.InviteSent,
false
)
val twoMember = MemberViewModel(
"Test User 2",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
MemberState.InviteFailed,
false
)
val threeMember = MemberViewModel(
"Test User 3",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
MemberState.Member,
false
)
val viewState = EditGroupViewState(
"Preview",
"This is a preview description",
listOf(oneMember, twoMember, threeMember),
true
)
PreviewTheme(themeResId = R.style.Classic_Dark) {
val oneMember = MemberViewModel(
"Test User",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
MemberState.InviteSent,
false
)
val twoMember = MemberViewModel(
"Test User 2",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235",
MemberState.InviteFailed,
false
)
val threeMember = MemberViewModel(
"Test User 3",
"05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236",
MemberState.Member,
false
)
val viewState = EditGroupViewState(
"Preview",
"This is a preview description",
listOf(oneMember, twoMember, threeMember),
true
)
EditGroupView(
onBack = {},
onInvite = {},
onReinvite = {},
onPromote = {},
onRemove = {},
onEditName = {},
viewState = viewState
)
}

View file

@ -23,6 +23,7 @@ val LocalPreviewMode = staticCompositionLocalOf { false }
data class ExtraColors(
val settingsBackground: Color,
val destructive: Color
)
/**
@ -35,6 +36,7 @@ fun AppTheme(
val extraColors = LocalContext.current.run {
ExtraColors(
settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground),
destructive = Color(getColor(R.color.destructive)),
)
}

View file

@ -1080,4 +1080,8 @@
<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="conversation_settings_leave_group_name">Are you sure you want to leave %1$s?</string>
<string name="dialog_edit_group_information_title">Update Group Information</string>
<string name="dialog_edit_group_information_message">Group name and description is visible to all group members.</string>
<string name="dialog_edit_group_information_enter_group_name">Enter group name</string>
<string name="dialog_edit_group_information_enter_group_description">Enter group description</string>
</resources>

View file

@ -174,6 +174,7 @@ interface StorageProtocol {
fun handlePromoted(keyPair: KeyPair)
fun handleMemberLeft(message: GroupUpdated, closedGroupId: SessionId)
fun leaveGroup(groupSessionId: String)
fun setName(groupSessionId: String, newName: String)
// Groups
fun getAllGroups(includeInactive: Boolean): List<GroupRecord>