Add verified group title tracking and syncing.

This commit is contained in:
Cody Henthorne
2026-04-17 15:52:56 -04:00
committed by GitHub
parent f680256f1d
commit 76e30ab09f
16 changed files with 440 additions and 47 deletions
@@ -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 {
@@ -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<RecipientId> {
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<RecipientId>): 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<ContentValues>()
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<GroupRecord> = 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)
@@ -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<SignalGroupV2Record>) {
@@ -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<SignalAccountRecord>) {
@@ -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) {
@@ -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")
}
}
@@ -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
@@ -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);
@@ -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 {
@@ -12,7 +12,8 @@ class GroupInfo(
val hasExistingContacts: Boolean = false,
val membersPreview: List<Recipient> = emptyList(),
val isMember: Boolean = false,
val isTerminated: Boolean = false
val isTerminated: Boolean = false,
val nameVerified: Boolean = false
) {
companion object {
@JvmField
@@ -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<Recipient> 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<Recipient> 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);
}
}
@@ -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)
@@ -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))
}
@@ -197,24 +197,24 @@ fun groupRecord(
): Optional<GroupRecord> {
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
)
)
}
@@ -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<ProfileKeySet>()) }
}
@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<GroupMasterKey>(), any<DecryptedGroup>(), anyNullable<ReceivedGroupSendEndorsements>(), anyNullable<RecipientId>(), 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<GroupMasterKey>(), any<DecryptedGroup>(), any<ReceivedGroupSendEndorsements>(), any<ByteArray>()) } 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<ByteArray> { 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()) }
}
}
@@ -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<RecipientTable>(relaxed = true), mockk<GroupTable>(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)
}
}
@@ -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 {