diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index e7258197c..89b8c9e63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -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() + + 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() + + 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) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt index 7e223fba7..417907b3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroup.kt @@ -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, + resultEditName: ResultRecipient, 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, + 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 ) +@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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index 5a9ece992..95f754557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -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)), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a01dcf5ac..1139fea25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1080,4 +1080,8 @@ Select Contacts Add Account ID or ONS Are you sure you want to leave %1$s? + Update Group Information + Group name and description is visible to all group members. + Enter group name + Enter group description diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 190305279..3c5171fdd 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -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