diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt index b332734091..98169e52f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.kt @@ -113,7 +113,7 @@ class ConversationHeaderView : AbstractComposeView { val isOfficialAccount = recipient.showVerified val showUnverifiedName = if (recipient.isGroup) { - !groupInfo.hasExistingContacts && !(groupInfo.fullMemberCount == 1 && groupInfo.isMember) + !info.groupInfo.nameVerified } else if (!isOfficialAccount) { recipient.nickname.isEmpty && !recipient.isSystemContact } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index c40c7afaff..3542122030 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -74,6 +74,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId import org.whispersystems.signalservice.api.push.DistributionId import java.io.Closeable +import java.security.MessageDigest import java.security.SecureRandom import java.time.Instant import java.util.Optional @@ -111,6 +112,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : const val SHOW_AS_STORY_STATE = "show_as_story_state" const val LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp" const val GROUP_SEND_ENDORSEMENTS_EXPIRATION = "group_send_endorsements_expiration" + const val V2_VERIFIED_NAME_HASH = "verified_name_hash" /** 32 bytes serialized [GroupMasterKey] */ const val V2_MASTER_KEY = "master_key" @@ -124,14 +126,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : @JvmField val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( - $ID INTEGER PRIMARY KEY, - $GROUP_ID TEXT NOT NULL UNIQUE, + $ID INTEGER PRIMARY KEY, + $GROUP_ID TEXT NOT NULL UNIQUE, $RECIPIENT_ID INTEGER NOT NULL UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, $TITLE TEXT DEFAULT NULL, - $AVATAR_ID INTEGER DEFAULT 0, + $AVATAR_ID INTEGER DEFAULT 0, $AVATAR_KEY BLOB DEFAULT NULL, - $AVATAR_CONTENT_TYPE TEXT DEFAULT NULL, - $AVATAR_DIGEST BLOB DEFAULT NULL, + $AVATAR_CONTENT_TYPE TEXT DEFAULT NULL, + $AVATAR_DIGEST BLOB DEFAULT NULL, $TIMESTAMP INTEGER DEFAULT 0, $IS_MEMBER INTEGER DEFAULT 1, $MMS INTEGER DEFAULT 0, @@ -144,7 +146,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : $SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code}, $LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0, $GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0, - $TERMINATED_BY INTEGER DEFAULT 0 + $TERMINATED_BY INTEGER DEFAULT 0, + $V2_VERIFIED_NAME_HASH BLOB DEFAULT NULL ) """ @@ -167,6 +170,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP, + V2_VERIFIED_NAME_HASH, LAST_FORCE_UPDATE_TIMESTAMP, GROUP_SEND_ENDORSEMENTS_EXPIRATION ) @@ -177,6 +181,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : .toList() val CREATE_TABLES = arrayOf(CREATE_TABLE, MembershipTable.CREATE_TABLE) + + @JvmStatic + fun computeVerifiedNameHash(title: String?): ByteArray? { + return title?.let { MessageDigest.getInstance("SHA-256").digest(it.toByteArray(Charsets.UTF_8)) } + } } class MembershipTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { @@ -544,6 +553,14 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : .run() } + fun setVerifiedGroupNameHash(groupId: GroupId.V2, verifiedNameHash: ByteArray?) { + writableDatabase + .update(TABLE_NAME) + .values(V2_VERIFIED_NAME_HASH to verifiedNameHash) + .where("$GROUP_ID = ?", groupId) + .run() + } + @WorkerThread fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List { return if (groupId.isV2) { @@ -607,19 +624,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : throw LegacyGroupInsertException(groupId) } - return create(groupId, title, members, avatar, null, null, null) + return create(groupId, title, members, avatar, null, null, null, null) } @CheckReturnValue fun create(groupId: GroupId.Mms, title: String?, members: Collection): Boolean { - return create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null) + return create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null, null) } @CheckReturnValue - fun create(groupMasterKey: GroupMasterKey, groupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?): GroupId.V2? { + @JvmOverloads + fun create( + groupMasterKey: GroupMasterKey, + groupState: DecryptedGroup, + groupSendEndorsements: ReceivedGroupSendEndorsements?, + verifiedNameHash: ByteArray? = null + ): GroupId.V2? { val groupId = GroupId.v2(groupMasterKey) - return if (create(groupId = groupId, title = groupState.title, memberCollection = emptyList(), avatar = null, groupMasterKey = groupMasterKey, groupState = groupState, receivedGroupSendEndorsements = groupSendEndorsements)) { + return if (create(groupId = groupId, title = groupState.title, memberCollection = emptyList(), avatar = null, groupMasterKey = groupMasterKey, groupState = groupState, receivedGroupSendEndorsements = groupSendEndorsements, verifiedNameHash = verifiedNameHash)) { groupId } else { null @@ -667,7 +690,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : avatar: SignalServiceAttachmentPointer?, groupMasterKey: GroupMasterKey?, groupState: DecryptedGroup?, - receivedGroupSendEndorsements: ReceivedGroupSendEndorsements? + receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?, + verifiedNameHash: ByteArray? ): Boolean { val membershipValues = mutableListOf() val groupRecipientId = recipients.getOrInsertFromGroupId(groupId) @@ -716,6 +740,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : values.put(V2_MASTER_KEY, groupMasterKey.serialize()) values.put(V2_REVISION, groupState.revision) values.put(V2_DECRYPTED_GROUP, groupState.encode()) + values.put(V2_VERIFIED_NAME_HASH, verifiedNameHash) membershipValues.clear() membershipValues.addAll(groupMembers.toContentValues(groupId, receivedGroupSendEndorsements?.toGroupSendEndorsementRecords())) } else { @@ -787,11 +812,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : notifyConversationListListeners() } - fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) { - update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements, terminatorRecipientId) + @JvmOverloads + fun update( + groupMasterKey: GroupMasterKey, + decryptedGroup: DecryptedGroup, + groupSendEndorsements: ReceivedGroupSendEndorsements?, + terminatorRecipientId: RecipientId? = null, + selfAuthoredTitle: Boolean = false + ) { + update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle) } - fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup, receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) { + @JvmOverloads + fun update( + groupId: GroupId.V2, + decryptedGroup: DecryptedGroup, + receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?, + terminatorRecipientId: RecipientId? = null, + selfAuthoredTitle: Boolean = false + ) { val groupRecipientId: RecipientId = recipients.getOrInsertFromGroupId(groupId) val existingGroup: Optional = getGroup(groupId) val title: String = decryptedGroup.title @@ -803,6 +842,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0) contentValues.put(TERMINATED_BY, terminatorRecipientId?.toLong() ?: if (decryptedGroup.terminated) -1 else 0) + if (selfAuthoredTitle) { + contentValues.put(V2_VERIFIED_NAME_HASH, computeVerifiedNameHash(title)) + } + if (receivedGroupSendEndorsements != null) { contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs) } @@ -1165,6 +1208,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY), groupRevision = cursor.requireInt(V2_REVISION), decryptedGroupBytes = cursor.requireBlob(V2_DECRYPTED_GROUP), + verifiedNameHash = cursor.requireBlob(V2_VERIFIED_NAME_HASH), distributionId = cursor.optionalString(DISTRIBUTION_ID).map { id -> DistributionId.from(id) }.orElse(null), lastForceUpdateTimestamp = cursor.requireLong(LAST_FORCE_UPDATE_TIMESTAMP), groupSendEndorsementExpiration = cursor.requireLong(GROUP_SEND_ENDORSEMENTS_EXPIRATION) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index e6ed22d4f5..7eb25c58b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -1013,6 +1013,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val masterKey = GroupMasterKey(insert.proto.masterKey.toByteArray()) val groupId = GroupId.v2(masterKey) val values = getValuesForStorageGroupV2(insert, true) + val verifiedNameHash: ByteArray? = insert.proto.verifiedNameHash.nullIfEmpty()?.toByteArray() val createdId = writableDatabase.withinTransaction { writableDatabase.insertOrThrow(TABLE_NAME, null, values) @@ -1021,12 +1022,14 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da groups.create( groupMasterKey = masterKey, groupState = DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION), - groupSendEndorsements = null + groupSendEndorsements = null, + verifiedNameHash = verifiedNameHash ) } if (createdId == null) { Log.w(TAG, "Unable to create restore placeholder for $groupId, group already exists") + groups.setVerifiedGroupNameHash(groupId, verifiedNameHash) } groups.setShowAsStoryState(groupId, insert.proto.storySendMode.toShowAsStoryState()) @@ -1040,7 +1043,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da Log.i(TAG, "Scheduling request for latest group info for $groupId") AppDependencies.jobManager.add(RequestGroupV2InfoJob(groupId)) threads.applyStorageSyncUpdate(recipient.id, insert) - recipient.live().refresh() + AppDependencies.databaseObserver.notifyRecipientChanged(recipient.id) } fun applyStorageSyncGroupV2Update(update: StorageRecordUpdate) { @@ -1060,8 +1063,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } groups.setShowAsStoryState(groupId, update.new.proto.storySendMode.toShowAsStoryState()) + groups.setVerifiedGroupNameHash(groupId, update.new.proto.verifiedNameHash.nullIfEmpty()?.toByteArray()) threads.applyStorageSyncUpdate(recipient.id, update.new) - recipient.live().refresh() + AppDependencies.databaseObserver.notifyRecipientChanged(recipient.id) } fun applyStorageSyncAccountUpdate(update: StorageRecordUpdate) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 2b452de00f..f3f377d920 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -168,6 +168,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V312_RefactorNameCo import org.thoughtcrime.securesms.database.helpers.migration.V313_AddCollapsingUpdateColumns import org.thoughtcrime.securesms.database.helpers.migration.V314_FixMessageRequestAcceptedToRecipient import org.thoughtcrime.securesms.database.helpers.migration.V315_CleanupE164SenderKeyShared +import org.thoughtcrime.securesms.database.helpers.migration.V316_AddVerifiedGroupNameHashMigration import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -343,10 +344,11 @@ object SignalDatabaseMigrations { 312 to V312_RefactorNameCollisionTables, 313 to V313_AddCollapsingUpdateColumns, 314 to V314_FixMessageRequestAcceptedToRecipient, - 315 to V315_CleanupE164SenderKeyShared + 315 to V315_CleanupE164SenderKeyShared, + 316 to V316_AddVerifiedGroupNameHashMigration ) - const val DATABASE_VERSION = 315 + const val DATABASE_VERSION = 316 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V316_AddVerifiedGroupNameHashMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V316_AddVerifiedGroupNameHashMigration.kt new file mode 100644 index 0000000000..26d43ef1cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V316_AddVerifiedGroupNameHashMigration.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +@Suppress("ClassName") +object V316_AddVerifiedGroupNameHashMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE groups ADD COLUMN verified_name_hash BLOB DEFAULT NULL") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt index 7610d84b16..f67a0857b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -31,6 +31,7 @@ class GroupRecord( groupMasterKeyBytes: ByteArray?, groupRevision: Int, decryptedGroupBytes: ByteArray?, + val verifiedNameHash: ByteArray? = null, val distributionId: DistributionId?, val lastForceUpdateTimestamp: Long, val groupSendEndorsementExpiration: Long diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 4b2d5fe1d4..688e787a4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.ProfileUtil; import org.whispersystems.signalservice.api.groupsv2.DecryptChangeVerificationMode; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupExtensions; @@ -235,7 +236,7 @@ final class GroupManagerV2 { DecryptedGroup decryptedGroup = createGroupResponse.getGroup(); GroupMasterKey masterKey = groupSecretParams.getMasterKey(); ReceivedGroupSendEndorsements groupSendEndorsements = groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroup, createGroupResponse.getGroupSendEndorsementsResponse()); - GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup, groupSendEndorsements); + GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup, groupSendEndorsements, GroupTable.computeVerifiedNameHash(decryptedGroup.title)); if (groupId == null) { throw new GroupChangeFailedException("Unable to create group, group already exists"); @@ -745,7 +746,14 @@ final class GroupManagerV2 { } RecipientId terminatorRecipientId = (decryptedGroupState.terminated && !previousGroupState.terminated) ? Recipient.self().getId() : null; - groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId); + boolean selfAuthoredTitle = changeActions.modifyTitle != null; + groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId, selfAuthoredTitle); + + if (selfAuthoredTitle) { + RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); + SignalDatabase.recipients().rotateStorageId(groupRecipientId, false); + StorageSyncHelper.scheduleSyncForDataChange(); + } GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState); RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange, sendToMembers); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 4516d3c0c7..d1cd652ef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -18,6 +18,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.signal.libsignal.zkgroup.groups.GroupSecretParams import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange +import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter @@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil @@ -635,6 +637,7 @@ class GroupsV2StateProcessor private constructor( val terminatorRecipientId: RecipientId? = if (wasTerminated) { groupStateDiff .serverHistory + .asSequence() .mapNotNull { it.change } .firstOrNull { it.terminateGroup } ?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) } @@ -643,17 +646,42 @@ class GroupsV2StateProcessor private constructor( null } + val selfAuthoredTitle: Boolean = run { + val lastTitleChange = groupStateDiff + .serverHistory + .asSequence() + .mapNotNull { it.change } + .lastOrNull { it.newTitle != null } + + if (lastTitleChange != null) { + return@run ServiceId.parseOrNull(lastTitleChange.editorServiceIdBytes) == serviceIds.aci + } + + if (previousGroupState == null && updatedGroupState.revision == 0) { + val rev0Editor = groupStateDiff + .serverHistory + .firstOrNull { it.group?.revision == 0 } + ?.change + ?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) } + + return@run rev0Editor == serviceIds.aci + } + + false + } + val needsAvatarFetch = if (previousGroupState == null) { - val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState, groupSendEndorsements) + val verifiedNameHash: ByteArray? = if (selfAuthoredTitle) GroupTable.computeVerifiedNameHash(updatedGroupState.title) else null + val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState, groupSendEndorsements, verifiedNameHash) if (groupId == null) { Log.w(TAG, "$logPrefix Group create failed, trying to update") - SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId) + SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle) } updatedGroupState.avatar.isNotEmpty() } else { - SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId) + SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId, selfAuthoredTitle) updatedGroupState.avatar != previousGroupState.avatar } @@ -667,6 +695,10 @@ class GroupsV2StateProcessor private constructor( AppDependencies.jobManager.add(AvatarGroupsV2DownloadJob(groupId, updatedGroupState.avatar)) } + if (selfAuthoredTitle) { + profileAndMessageHelper.scheduleStorageServiceSync() + } + profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState, needsAvatarFetch) } @@ -1005,6 +1037,13 @@ class GroupsV2StateProcessor private constructor( return Optional.empty() } + fun scheduleStorageServiceSync() { + val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId) + SignalDatabase.recipients.rotateStorageId(groupRecipientId) + Recipient.live(groupRecipientId).refresh() + StorageSyncHelper.scheduleSyncForDataChange() + } + companion object { @VisibleForTesting fun create(aci: ACI, masterKey: GroupMasterKey, groupId: GroupId.V2): ProfileAndMessageHelper { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt index 748a3dda7a..0248c26ed4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt @@ -12,7 +12,8 @@ class GroupInfo( val hasExistingContacts: Boolean = false, val membersPreview: List = emptyList(), val isMember: Boolean = false, - val isTerminated: Boolean = false + val isTerminated: Boolean = false, + val nameVerified: Boolean = false ) { companion object { @JvmField diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 40a1482020..3d33c70b5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -31,11 +31,10 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.MessageSender; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import java.io.IOException; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; @@ -73,12 +72,13 @@ public final class MessageRequestRepository { boolean groupHasExistingContacts = recipients.stream().filter(r -> !r.isSelf()).anyMatch(r -> r.isProfileSharing() || r.isSystemContact()); List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup(); + boolean nameVerified = groupRecord.get().getVerifiedNameHash() != null && Arrays.equals(GroupTable.computeVerifiedNameHash(groupRecord.get().getTitle()), groupRecord.get().getVerifiedNameHash()); - groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated()); + groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated(), nameVerified); } else { List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); - groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false); + groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false, false); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt index 64cfd60248..1d57993a2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.storage +import org.signal.core.util.isNotEmpty import org.signal.core.util.logging.Log import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.database.GroupTable @@ -60,6 +61,7 @@ class GroupV2RecordProcessor(private val recipientTable: RecipientTable, private hideStory = remote.proto.hideStory storySendMode = remote.proto.storySendMode avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor + verifiedNameHash = if (remote.proto.verifiedNameHash.isNotEmpty()) remote.proto.verifiedNameHash else local.proto.verifiedNameHash }.build().toSignalGroupV2Record(StorageId.forGroupV2(keyGenerator.generate())) val matchesRemote = doParamsMatch(remote, merged) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index 224fd0afce..1684d7839a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -242,6 +242,8 @@ object StorageSyncModels { throw AssertionError("Group is not V2") } + val localVerifiedNameHash: ByteArray? = groups.getGroup(groupId).orElse(null)?.verifiedNameHash + return SignalGroupV2Record.newBuilder(recipient.syncExtras.storageProto).apply { masterKey = groupMasterKey.serialize().toByteString() blocked = recipient.isBlocked @@ -257,6 +259,9 @@ object StorageSyncModels { ShowAsStoryState.NEVER -> GroupV2Record.StorySendMode.DISABLED else -> GroupV2Record.StorySendMode.DEFAULT } + if (localVerifiedNameHash != null) { + verifiedNameHash = localVerifiedNameHash.toByteString() + } }.build().toSignalGroupV2Record(StorageId.forGroupV2(rawStorageId)) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index d9f1775066..a26f68f8cb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -197,24 +197,24 @@ fun groupRecord( ): Optional { return Optional.of( GroupRecord( - id, - recipientId, - decryptedGroup.title, - members, - unmigratedV1Members, - avatarId, - avatarKey, - avatarContentType, - active, + id = id, + recipientId = recipientId, + title = decryptedGroup.title, + serializedMembers = members, + serializedUnmigratedV1Members = unmigratedV1Members, + avatarId = avatarId, + avatarKey = avatarKey, + avatarContentType = avatarContentType, + isMember = active, terminatedBy = if (decryptedGroup.terminated) -1L else 0L, - avatarDigest, - mms, - masterKey.serialize(), - decryptedGroup.revision, - decryptedGroup.encode(), - distributionId, - System.currentTimeMillis(), - 0 + avatarDigest = avatarDigest, + isMms = mms, + groupMasterKeyBytes = masterKey.serialize(), + groupRevision = decryptedGroup.revision, + decryptedGroupBytes = decryptedGroup.encode(), + distributionId = distributionId, + lastForceUpdateTimestamp = System.currentTimeMillis(), + groupSendEndorsementExpiration = 0 ) ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index 280428ce0f..b5c7dddb6f 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.SystemOutLogger import org.whispersystems.signalservice.api.NetworkResult @@ -1316,4 +1317,149 @@ class GroupsV2StateProcessorTest { verify { groupTable.update(masterKey, result.latestServer!!, null) } verify(exactly = 0) { profileAndMessageHelper.persistLearnedProfileKeys(any()) } } + + @Test + fun `when self authors a title change, then update is called with selfAuthoredTitle true and storage is rotated`() { + given { + localState( + revision = 5, + title = "Old", + members = selfAndOthers + ) + changeSet { + changeLog(6) { + change { + editorServiceIdBytes = selfAci.toByteString() + setNewTitle("New") + } + } + } + apiCallParameters(requestedRevision = 5, includeFirst = false) + joinedAtRevision = 0 + expectTableUpdate = true + } + + val recipientId = RecipientId.from(100) + every { recipientTable.getOrInsertFromGroupId(groupId) } returns recipientId + justRun { recipientTable.rotateStorageId(any()) } + justRun { groupTable.update(any(), any(), anyNullable(), anyNullable(), eq(true)) } + mockkStatic(StorageSyncHelper::class) + justRun { StorageSyncHelper.scheduleSyncForDataChange() } + + val result = processor.updateLocalGroupToRevision( + targetRevision = GroupsV2StateProcessor.LATEST, + timestamp = 0 + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + verify { groupTable.update(masterKey, result.latestServer!!, null, null, true) } + verify { recipientTable.rotateStorageId(recipientId) } + verify { StorageSyncHelper.scheduleSyncForDataChange() } + + unmockkStatic(StorageSyncHelper::class) + } + + @Test + fun `when another member authors a title change, then update is called with selfAuthoredTitle false`() { + given { + localState( + revision = 5, + title = "Old", + members = selfAndOthers + ) + changeSet { + changeLog(6) { + change { + editorServiceIdBytes = otherAci.toByteString() + setNewTitle("New") + } + } + } + apiCallParameters(requestedRevision = 5, includeFirst = false) + joinedAtRevision = 0 + expectTableUpdate = true + } + + val result = processor.updateLocalGroupToRevision( + targetRevision = GroupsV2StateProcessor.LATEST, + timestamp = 0 + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + verify { groupTable.update(masterKey, result.latestServer!!, null, null, false) } + verify(exactly = 0) { recipientTable.rotateStorageId(any()) } + } + + @Test + fun `when self is editor of a fresh rev 0 group without explicit title change, then create is called with computed verified name hash`() { + given { + changeSet { + changeLog(0) { + fullSnapshot(title = "Fresh", members = selfAndOthers) + change { + editorServiceIdBytes = selfAci.toByteString() + } + } + } + apiCallParameters(requestedRevision = 0, includeFirst = true) + joinedAtRevision = 0 + expectTableCreate = true + } + + val recipientId = RecipientId.from(100) + every { recipientTable.getOrInsertFromGroupId(groupId) } returns recipientId + justRun { recipientTable.rotateStorageId(any()) } + justRun { profileAndMessageHelper.setProfileSharing(any(), any(), any()) } + every { groupTable.create(any(), any(), any(), any()) } returns groupId + mockkStatic(StorageSyncHelper::class) + justRun { StorageSyncHelper.scheduleSyncForDataChange() } + + val result = processor.updateLocalGroupToRevision( + targetRevision = 0, + timestamp = 0 + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + val expectedHash = GroupTable.computeVerifiedNameHash("Fresh")!! + verify { + groupTable.create( + masterKey, + result.latestServer!!, + null, + match { it.contentEquals(expectedHash) } + ) + } + verify { recipientTable.rotateStorageId(recipientId) } + verify { StorageSyncHelper.scheduleSyncForDataChange() } + + unmockkStatic(StorageSyncHelper::class) + } + + @Test + fun `when another member is editor of a fresh rev 0 group, then create is called without a verified name hash`() { + given { + changeSet { + changeLog(0) { + fullSnapshot(title = "Fresh", members = selfAndOthers) + change { + editorServiceIdBytes = otherAci.toByteString() + } + } + } + apiCallParameters(requestedRevision = 0, includeFirst = true) + joinedAtRevision = 0 + expectTableCreate = true + } + + justRun { profileAndMessageHelper.setProfileSharing(any(), any(), any()) } + + val result = processor.updateLocalGroupToRevision( + targetRevision = 0, + timestamp = 0 + ) + + assertThat(result.updateStatus).isEqualTo(GroupUpdateResult.UpdateStatus.GROUP_UPDATED) + verify { groupTable.create(masterKey, result.latestServer!!, null, null) } + verify(exactly = 0) { recipientTable.rotateStorageId(any()) } + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessorTest.kt new file mode 100644 index 0000000000..bff43b8705 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessorTest.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.storage + +import android.app.Application +import io.mockk.every +import io.mockk.mockk +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.Hex.fromStringCondensed +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.keyvalue.AccountValues +import org.thoughtcrime.securesms.testutil.MockSignalStoreRule +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class GroupV2RecordProcessorTest { + + companion object { + private val MASTER_KEY = GroupMasterKey(fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")).serialize().toByteString() + private val HASH_A = ByteArray(32) { 0xAA.toByte() } + private val HASH_B = ByteArray(32) { 0xBB.toByte() } + + private val random = Random() + + private fun record(verifiedNameHash: ByteArray?): SignalGroupV2Record { + val proto = GroupV2Record.Builder() + .masterKey(MASTER_KEY) + .verifiedNameHash(verifiedNameHash?.toByteString() ?: ByteString.EMPTY) + .build() + return SignalGroupV2Record(StorageId.forGroupV2(randomKey()), proto) + } + + private fun randomKey(): ByteArray { + val key = ByteArray(16) + random.nextBytes(key) + return key + } + + private fun keyGenerator(): StorageKeyGenerator { + return StorageKeyGenerator { randomKey() } + } + } + + @get:Rule + val signalStore = MockSignalStoreRule(relaxed = setOf(AccountValues::class)) + + private lateinit var processor: GroupV2RecordProcessor + + @Before + fun setUp() { + every { signalStore.account.isPrimaryDevice } returns true + + processor = GroupV2RecordProcessor(mockk(relaxed = true), mockk(relaxed = true)) + } + + @Test + fun `merge prefers remote hash when remote is non-empty`() { + val local = record(verifiedNameHash = HASH_A) + val remote = record(verifiedNameHash = HASH_B) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertArrayEquals(HASH_B, merged.proto.verifiedNameHash.toByteArray()) + } + + @Test + fun `merge keeps local hash when remote hash is empty`() { + val local = record(verifiedNameHash = HASH_A) + val remote = record(verifiedNameHash = null) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertArrayEquals(HASH_A, merged.proto.verifiedNameHash.toByteArray()) + } + + @Test + fun `merge takes remote hash when local hash is empty`() { + val local = record(verifiedNameHash = null) + val remote = record(verifiedNameHash = HASH_B) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertArrayEquals(HASH_B, merged.proto.verifiedNameHash.toByteArray()) + } + + @Test + fun `merge yields empty hash when both are empty`() { + val local = record(verifiedNameHash = null) + val remote = record(verifiedNameHash = null) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertTrue(merged.proto.verifiedNameHash.size == 0) + } + + @Test + fun `merge returns local instance when local already matches merged`() { + val local = record(verifiedNameHash = HASH_A) + val remote = record(verifiedNameHash = null) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertEquals(local.id, merged.id) + } + + @Test + fun `merge returns remote instance when remote already matches merged`() { + val local = record(verifiedNameHash = null) + val remote = record(verifiedNameHash = HASH_B) + + val merged = processor.merge(remote, local, keyGenerator()) + + assertEquals(remote.id, merged.id) + } +} diff --git a/lib/libsignal-service/src/main/protowire/StorageService.proto b/lib/libsignal-service/src/main/protowire/StorageService.proto index d22babc235..dd232caca2 100644 --- a/lib/libsignal-service/src/main/protowire/StorageService.proto +++ b/lib/libsignal-service/src/main/protowire/StorageService.proto @@ -172,6 +172,7 @@ message GroupV2Record { reserved /* storySendEnabled */ 9; StorySendMode storySendMode = 10; optional AvatarColor avatarColor = 11; + bytes verifiedNameHash = 12; // SHA-256 of UTF-8 encoded decrypted group title that was last verified } message Payments {