mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-15 20:50:35 +01:00
Add verified group title tracking and syncing.
This commit is contained in:
@@ -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>) {
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
+42
-3
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+146
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user