sync last verified group name hash

This commit is contained in:
kate-signal 2026-05-05 15:29:26 -04:00 committed by GitHub
parent 719a3ee0bd
commit b5d12fcab0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 255 additions and 47 deletions

View File

@ -1429,6 +1429,7 @@ private extension CVComponentState.Builder {
return CVComponentThreadDetails.buildComponentState(
thread: thread,
threadAssociatedData: threadAssociatedData,
transaction: transaction,
avatarBuilder: avatarBuilder,
)

View File

@ -432,6 +432,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
static func buildComponentState(
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
transaction: DBReadTransaction,
avatarBuilder: CVAvatarBuilder,
) -> CVComponentState.ThreadDetails {
@ -444,6 +445,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
} else if let groupThread = thread as? TSGroupThread {
return buildComponentState(
groupThread: groupThread,
threadAssociatedData: threadAssociatedData,
transaction: transaction,
avatarBuilder: avatarBuilder,
)
@ -519,6 +521,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
private static func buildComponentState(
groupThread: TSGroupThread,
threadAssociatedData: ThreadAssociatedData,
transaction: DBReadTransaction,
avatarBuilder: CVAvatarBuilder,
) -> CVComponentState.ThreadDetails {
@ -541,6 +544,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
let safetySection = Self.buildGroupsSafetySection(
from: groupThread,
threadAssociatedData: threadAssociatedData,
tx: transaction,
)
let descriptionText: String? = {
@ -815,6 +819,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
extension CVComponentThreadDetails {
private static func buildGroupsSafetySection(
from groupThread: TSGroupThread,
threadAssociatedData: ThreadAssociatedData,
tx: DBReadTransaction,
) -> CVComponentState.ThreadDetails.SafetySection {
let accountManager = DependenciesBridge.shared.tsAccountManager
@ -932,7 +937,7 @@ extension CVComponentThreadDetails {
.color(Self.mutualGroupsTextColor),
)
let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustGroup(groupThread: groupThread, tx: tx)
let shouldShowUnknownThreadWarning = !threadAssociatedData.isGroupNameVerified(groupName: groupThread.groupNameOrDefault)
return .init(
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,

View File

@ -163,6 +163,7 @@ public final class BackupArchiveThreadStore {
isMarkedUnread: isMarkedUnread,
mutedUntilTimestamp: mutedUntilTimestamp ?? 0,
audioPlaybackRate: 1,
lastVerifiedGroupNameHash: nil,
)
try threadAssociatedData.insert(context.tx.database)
}

View File

@ -262,28 +262,6 @@ private class SystemContactsCache {
// MARK: -
extension OWSContactsManager: ContactManager {
public func isLowTrustGroup(groupThread: TSGroupThread, tx: DBReadTransaction) -> Bool {
if groupIdsNotNeedingLowTrustWarningCache.contains(groupThread.groupId) {
return false
}
if SSKEnvironment.shared.profileManagerRef.isThread(inProfileWhitelist: groupThread, transaction: tx) {
groupIdsNotNeedingLowTrustWarningCache.insert(groupThread.groupId)
return false
}
if !groupThread.hasPendingMessageRequest(transaction: tx) {
return false
}
// We can skip "unknown thread warnings" if a group has members which are trusted.
if hasWhitelistedGroupMember(groupThread: groupThread, tx: tx) {
groupIdsNotNeedingLowTrustWarningCache.insert(groupThread.groupId)
return false
}
return true
}
private func isInWhitelistedGroupsWithLocalUser(
otherAddress: SignalServiceAddress,
requireMultipleMutualGroups: Bool,
@ -318,12 +296,6 @@ extension OWSContactsManager: ContactManager {
return false
}
private func hasWhitelistedGroupMember(groupThread: TSGroupThread, tx: DBReadTransaction) -> Bool {
groupThread.groupMembership.fullMembers.contains { member in
SSKEnvironment.shared.profileManagerRef.isUser(inProfileWhitelist: member, transaction: tx)
}
}
// MARK: - Avatar Blurring
public func didTapToUnblurAvatar(for thread: TSThread) {

View File

@ -3,6 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
public import GRDB
@ -18,6 +19,10 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
public private(set) var mutedUntilTimestamp: UInt64 = 0
public private(set) var audioPlaybackRate: Float = 1
// The last group name that was set by the local user.
// Nil if it has never been set by the local user.
public private(set) var lastVerifiedGroupNameHash: Data?
public var isMuted: Bool { mutedUntilTimestamp > Date.ows_millisecondTimestamp() }
public var mutedUntilDate: Date? {
@ -25,6 +30,19 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
return Date(millisecondsSince1970: mutedUntilTimestamp)
}
static func groupNameVerificationHash(groupName: String?) -> Data? {
guard let groupName, let groupNameData = groupName.data(using: .utf8) else {
return nil
}
var sha = SHA256()
sha.update(data: groupNameData)
return Data(sha.finalize())
}
public func isGroupNameVerified(groupName: String) -> Bool {
return lastVerifiedGroupNameHash == Self.groupNameVerificationHash(groupName: groupName)
}
public static var alwaysMutedTimestamp: UInt64 { UInt64(LLONG_MAX) }
public static func fetchOrDefault(
@ -97,6 +115,7 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
case isMarkedUnread
case mutedUntilTimestamp
case audioPlaybackRate
case lastVerifiedGroupNameHash
}
public required init(from decoder: any Decoder) throws {
@ -115,6 +134,9 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
if let audioPlaybackRate = try container.decodeIfPresent(Float.self, forKey: .audioPlaybackRate) {
self.audioPlaybackRate = audioPlaybackRate
}
if let lastVerifiedGroupNameHash = try container.decodeIfPresent(Data.self, forKey: .lastVerifiedGroupNameHash) {
self.lastVerifiedGroupNameHash = lastVerifiedGroupNameHash
}
}
public func encode(to encoder: any Encoder) throws {
@ -125,6 +147,7 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
try container.encode(self.isMarkedUnread, forKey: .isMarkedUnread)
try container.encode(Int64(bitPattern: self.mutedUntilTimestamp), forKey: .mutedUntilTimestamp)
try container.encode(self.audioPlaybackRate, forKey: .audioPlaybackRate)
try container.encodeIfPresent(lastVerifiedGroupNameHash, forKey: .lastVerifiedGroupNameHash)
}
public func didInsert(with rowID: Int64, for column: String?) {
@ -137,12 +160,14 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
isMarkedUnread: Bool,
mutedUntilTimestamp: UInt64,
audioPlaybackRate: Float,
lastVerifiedGroupNameHash: Data?,
) {
self.threadUniqueId = threadUniqueId
self.isArchived = isArchived
self.isMarkedUnread = isMarkedUnread
self.mutedUntilTimestamp = mutedUntilTimestamp
self.audioPlaybackRate = audioPlaybackRate
self.lastVerifiedGroupNameHash = lastVerifiedGroupNameHash
super.init()
}
@ -151,6 +176,7 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
isMarkedUnread: Bool? = nil,
mutedUntilTimestamp: UInt64? = nil,
audioPlaybackRate: Float? = nil,
lastVerifiedGroupNameHash: Data? = nil,
updateStorageService: Bool,
transaction: DBWriteTransaction,
) {
@ -159,6 +185,7 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
|| isMarkedUnread != nil
|| mutedUntilTimestamp != nil
|| audioPlaybackRate != nil
|| lastVerifiedGroupNameHash != nil
else {
return owsFailDebug("You must set one value")
}
@ -176,6 +203,9 @@ public class ThreadAssociatedData: NSObject, Codable, FetchableRecord, Persistab
if let audioPlaybackRate {
associatedData.audioPlaybackRate = audioPlaybackRate
}
if let lastVerifiedGroupNameHash {
associatedData.lastVerifiedGroupNameHash = lastVerifiedGroupNameHash
}
}
}

View File

@ -108,6 +108,9 @@ final class ThreadMerger {
? valuePair.intoValue.audioPlaybackRate
: valuePair.fromValue.audioPlaybackRate,
// Only group threads track the last verified name hash.
lastVerifiedGroupNameHash: nil,
)
func newValueIfChanged<T: Equatable>(_ keyPath: KeyPath<ThreadAssociatedData, T>) -> T? {
@ -122,6 +125,7 @@ final class ThreadMerger {
isMarkedUnread: newValueIfChanged(\.isMarkedUnread),
mutedUntilTimestamp: newValueIfChanged(\.mutedUntilTimestamp),
audioPlaybackRate: newValueIfChanged(\.audioPlaybackRate),
lastVerifiedGroupNameHash: nil,
updateStorageService: true,
tx: tx,
)
@ -421,6 +425,7 @@ protocol _ThreadMerger_ThreadAssociatedDataManagerShim {
isMarkedUnread: Bool?,
mutedUntilTimestamp: UInt64?,
audioPlaybackRate: Float?,
lastVerifiedGroupNameHash: Data?,
updateStorageService: Bool,
tx: DBWriteTransaction,
)
@ -433,6 +438,7 @@ class _ThreadMerger_ThreadAssociatedDataManagerWrapper: _ThreadMerger_ThreadAsso
isMarkedUnread: Bool?,
mutedUntilTimestamp: UInt64?,
audioPlaybackRate: Float?,
lastVerifiedGroupNameHash: Data?,
updateStorageService: Bool,
tx: DBWriteTransaction,
) {
@ -444,6 +450,7 @@ class _ThreadMerger_ThreadAssociatedDataManagerWrapper: _ThreadMerger_ThreadAsso
isMarkedUnread: isMarkedUnread,
mutedUntilTimestamp: mutedUntilTimestamp,
audioPlaybackRate: audioPlaybackRate,
lastVerifiedGroupNameHash: lastVerifiedGroupNameHash,
updateStorageService: updateStorageService,
transaction: tx,
)
@ -528,6 +535,7 @@ class ThreadMerger_MockThreadAssociatedDataManager: ThreadMerger.Shims.ThreadAss
isMarkedUnread: Bool?,
mutedUntilTimestamp: UInt64?,
audioPlaybackRate: Float?,
lastVerifiedGroupNameHash: Data?,
updateStorageService: Bool,
tx: DBWriteTransaction,
) {
@ -537,6 +545,7 @@ class ThreadMerger_MockThreadAssociatedDataManager: ThreadMerger.Shims.ThreadAss
isMarkedUnread: isMarkedUnread ?? threadAssociatedData.isMarkedUnread,
mutedUntilTimestamp: mutedUntilTimestamp ?? threadAssociatedData.mutedUntilTimestamp,
audioPlaybackRate: audioPlaybackRate ?? threadAssociatedData.audioPlaybackRate,
lastVerifiedGroupNameHash: lastVerifiedGroupNameHash ?? threadAssociatedData.lastVerifiedGroupNameHash,
)
}
}

View File

@ -146,6 +146,12 @@ public class GroupManager: NSObject {
groupV2Snapshot: snapshotResponse.groupSnapshot,
transaction: tx,
)
// Since local user created the group, it's name is verified.
let lastVerifiedGroupNameHash = ThreadAssociatedData.groupNameVerificationHash(
groupName: snapshotResponse.groupSnapshot.title,
)
let groupModel = try builder.buildAsV2()
let thread = self.insertGroupThreadInDatabaseAndCreateInfoMessage(
@ -155,6 +161,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
lastVerifiedGroupNameHash: lastVerifiedGroupNameHash,
transaction: tx,
)
SSKEnvironment.shared.profileManagerRef.addGroupId(
@ -243,6 +250,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .unreportable,
updatedLastVerifiedGroupNameHash: nil,
transaction: transaction,
)
}
@ -714,6 +722,7 @@ public class GroupManager: NSObject {
//
// newDisappearingMessageToken is nil because we don't want to change DM
// state.
// updatedLastVerifiedGroupNameHash is nil because this is not a change-name action.
updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
groupThread: groupThread,
newGroupModel: newGroupModel,
@ -723,6 +732,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: .createdByLocalAction,
updatedLastVerifiedGroupNameHash: nil,
transaction: tx,
)
} catch {
@ -827,8 +837,10 @@ public class GroupManager: NSObject {
infoMessagePolicy: InfoMessagePolicy,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
lastVerifiedGroupNameHash: Data?,
transaction: DBWriteTransaction,
) -> TSGroupThread {
let threadAssociatedDataStore = DependenciesBridge.shared.threadAssociatedDataStore
if let groupThread = TSGroupThread.fetch(groupId: groupModel.groupId, transaction: transaction) {
owsFail("Inserting existing group thread: \(groupThread.logString).")
@ -839,6 +851,14 @@ public class GroupManager: NSObject {
tx: transaction,
)
if let lastVerifiedGroupNameHash {
if let threadAssociatedData = threadAssociatedDataStore.fetch(for: groupThread.uniqueId, tx: transaction) {
threadAssociatedData.updateWith(lastVerifiedGroupNameHash: lastVerifiedGroupNameHash, updateStorageService: true, transaction: transaction)
} else {
owsFailDebug("missing threadAssociatedData for group")
}
}
let newDisappearingMessageToken = disappearingMessageToken ?? DisappearingMessageToken.disabledToken
_ = updateDisappearingMessageConfiguration(
newToken: newDisappearingMessageToken,
@ -894,6 +914,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: InfoMessagePolicy,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
updatedLastVerifiedGroupNameHash: Data?,
transaction: DBWriteTransaction,
) -> TSGroupThread {
if DebugFlags.internalLogging {
@ -911,6 +932,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
updatedLastVerifiedGroupNameHash: updatedLastVerifiedGroupNameHash,
transaction: transaction,
)
return groupThread
@ -948,6 +970,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: infoMessagePolicy,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
lastVerifiedGroupNameHash: updatedLastVerifiedGroupNameHash,
transaction: transaction,
)
}
@ -969,6 +992,7 @@ public class GroupManager: NSObject {
infoMessagePolicy: InfoMessagePolicy = .insert,
localIdentifiers: LocalIdentifiers,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
updatedLastVerifiedGroupNameHash: Data?,
transaction: DBWriteTransaction,
) {
guard
@ -1106,6 +1130,12 @@ public class GroupManager: NSObject {
transaction: transaction,
)
if let updatedLastVerifiedGroupNameHash {
let threadAssociatedDataStore = DependenciesBridge.shared.threadAssociatedDataStore
let threadAssociatedData = threadAssociatedDataStore.fetch(for: groupThread.uniqueId, tx: transaction)
threadAssociatedData?.updateWith(lastVerifiedGroupNameHash: updatedLastVerifiedGroupNameHash, updateStorageService: true, transaction: transaction)
}
let shouldInsertInfoMessages: Bool
switch infoMessagePolicy {
case .insert:
@ -1441,6 +1471,9 @@ extension GroupManager {
title != existingGroupModel.groupName
{
groupChangeSet.setTitle(title)
// Updated verified group name hash in storage service.
SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(groupModel: existingGroupModel)
}
if

View File

@ -148,6 +148,10 @@ public class GroupV2UpdatesImpl: GroupV2Updates {
// The prior method throws if the revisions don't match.
owsAssertDebug(changedGroupModel.newGroupModel.revision == changedGroupModel.oldGroupModel.revision + 1)
var updatedLastVerifiedGroupNameHash: Data?
if changedGroupModel.shouldUpdateLastVerifiedGroupNameHash {
updatedLastVerifiedGroupNameHash = ThreadAssociatedData.groupNameVerificationHash(groupName: changedGroupModel.newGroupModel.groupName)
}
GroupManager.updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
groupThread: groupThread,
newGroupModel: changedGroupModel.newGroupModel,
@ -156,6 +160,7 @@ public class GroupV2UpdatesImpl: GroupV2Updates {
groupUpdateSource: changedGroupModel.updateSource,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
updatedLastVerifiedGroupNameHash: updatedLastVerifiedGroupNameHash,
transaction: transaction,
)
// The prior method always updates the revision because we've confirmed it's newer.
@ -181,7 +186,6 @@ public class GroupV2UpdatesImpl: GroupV2Updates {
tx: transaction,
)
}
return groupThread
}
@ -416,6 +420,35 @@ public extension GroupV2UpdatesImpl {
try await SSKEnvironment.shared.groupsV2Ref.downloadAndApplyGroupAvatarIfSkipped(secretParams)
}
private func lastVerifiedHashIfLocalUserCreatedGroup(secretParams: GroupSecretParams, localIdentifiers: LocalIdentifiers) async -> Data? {
var lastVerifiedHash: Data?
do {
let groupsV2 = SSKEnvironment.shared.groupsV2Ref
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
let firstChangeAction = try await groupsV2.fetchRevisionZeroGroupChangeAction(secretParams: secretParams)
let author = try firstChangeAction.author(groupV2Params: groupV2Params, localIdentifiers: localIdentifiers)
switch author {
case .localUser:
if let verificationHash = ThreadAssociatedData.groupNameVerificationHash(groupName: firstChangeAction.snapshot?.title) {
lastVerifiedHash = verificationHash
}
default:
break
}
return lastVerifiedHash
} catch GroupsV2Error.localUserNotInGroup {
return nil
} catch {
owsFailDebug("Failed to fetch change action for revision 0 with an unexpected error: \(error)")
// This is a best-effort check to determine if the local user created the group
// in case the device that created the group was not on an updated build.
// If it fails with an unexpected error, return nil and if the creating device
// is up to date we will get the updated value from storage service.
return nil
}
}
private func tryToApplyGroupChangesFromService(
secretParams: GroupSecretParams,
spamReportingMetadata: GroupUpdateSpamReportingMetadata,
@ -423,18 +456,33 @@ public extension GroupV2UpdatesImpl {
groupSendEndorsementsResponse: GroupSendEndorsementsResponse?,
options: TSGroupModelOptions,
) async throws {
guard
let localIdentifiers = SSKEnvironment.shared.databaseStorageRef.read(block: { tx in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
return tsAccountManager.localIdentifiers(tx: tx)
})
else {
throw OWSAssertionError("Missing localIdentifiers.")
}
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let threadExists = SSKEnvironment.shared.databaseStorageRef.read { transaction in
TSGroupThread.fetch(forGroupId: groupId, tx: transaction) != nil
}
var lastVerifiedGroupNameHash: Data?
if !threadExists {
// Only check rev0 if we're about to insert a new thread.
lastVerifiedGroupNameHash = await lastVerifiedHashIfLocalUserCreatedGroup(
secretParams: secretParams,
localIdentifiers: localIdentifiers,
)
}
try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction) else {
throw OWSAssertionError("Missing localIdentifiers.")
}
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier()
let groupThread: TSGroupThread
var localUserWasAddedBy: GroupUpdateSource?
let groupThread: TSGroupThread
if let existingThread = TSGroupThread.fetch(forGroupId: groupId, tx: transaction) {
groupThread = existingThread
localUserWasAddedBy = nil
@ -446,6 +494,7 @@ public extension GroupV2UpdatesImpl {
groupChanges: groupChanges,
groupModelOptions: options,
localIdentifiers: localIdentifiers,
lastVerifiedGroupNameHash: lastVerifiedGroupNameHash,
transaction: transaction,
)
}
@ -553,6 +602,7 @@ public extension GroupV2UpdatesImpl {
groupChanges: [GroupV2Change],
groupModelOptions: TSGroupModelOptions,
localIdentifiers: LocalIdentifiers,
lastVerifiedGroupNameHash: Data?,
transaction: DBWriteTransaction,
) throws -> (TSGroupThread, addedToNewThreadBy: GroupUpdateSource?) {
if TSGroupThread.fetch(forGroupId: groupId, tx: transaction) != nil {
@ -595,6 +645,7 @@ public extension GroupV2UpdatesImpl {
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
updatedLastVerifiedGroupNameHash: lastVerifiedGroupNameHash,
transaction: transaction,
)
@ -669,7 +720,10 @@ public extension GroupV2UpdatesImpl {
} else if let snapshot = groupChange.snapshot {
logger.info("Applying snapshot.")
var builder = try TSGroupModelBuilder.builderForSnapshot(groupV2Snapshot: snapshot, transaction: transaction)
var builder = try TSGroupModelBuilder.builderForSnapshot(
groupV2Snapshot: snapshot,
transaction: transaction,
)
builder.apply(options: options)
newGroupModel = try builder.build()
newDisappearingMessageToken = snapshot.disappearingMessageToken
@ -682,7 +736,6 @@ public extension GroupV2UpdatesImpl {
// not a single revision update.
throw GroupsV2Error.groupChangeProtoForIncompatibleRevision
}
GroupManager.updateExistingGroupThreadInDatabaseAndCreateInfoMessage(
groupThread: groupThread,
newGroupModel: newGroupModel,
@ -691,6 +744,7 @@ public extension GroupV2UpdatesImpl {
groupUpdateSource: groupUpdateSource,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
updatedLastVerifiedGroupNameHash: nil, // Nothing to update.
transaction: transaction,
)
@ -775,6 +829,7 @@ public extension GroupV2UpdatesImpl {
infoMessagePolicy: .insert,
localIdentifiers: localIdentifiers,
spamReportingMetadata: spamReportingMetadata,
updatedLastVerifiedGroupNameHash: nil,
transaction: transaction,
)

View File

@ -154,6 +154,8 @@ public protocol GroupsV2 {
transaction: DBWriteTransaction,
)
func fetchRevisionZeroGroupChangeAction(secretParams: GroupSecretParams) async throws -> GroupV2Change
func fetchSomeGroupChangeActions(
secretParams: GroupSecretParams,
source: GroupChangeActionFetchSource,
@ -614,6 +616,10 @@ public class MockGroupsV2: GroupsV2 {
owsFail("Not implemented")
}
public func fetchRevisionZeroGroupChangeAction(secretParams: LibSignalClient.GroupSecretParams) async throws -> GroupV2Change {
owsFail("not implemented")
}
public func fetchSomeGroupChangeActions(secretParams: GroupSecretParams, source: GroupChangeActionFetchSource) async throws -> GroupChangesResponse {
owsFail("not implemented")
}

View File

@ -254,8 +254,6 @@ public extension GroupsV2Impl {
return true
}
// This will try to update the group using incremental "changes" but
// failover to using a "snapshot".
do {
try await SSKEnvironment.shared.groupV2UpdatesRef.fetchAndApplyCurrentGroupV2SnapshotFromService(
secretParams: groupContextInfo.groupSecretParams,

View File

@ -644,6 +644,37 @@ public class GroupsV2Impl: GroupsV2 {
// MARK: - Fetch Group Change Actions
/// Fetches change actions starting at revision 0.
/// Checking the author of the first change can determine in most cases if the local user was the creator of the group.
/// If a GroupsV2.localUserNotInGroup error is thrown fetching the first change, the local user either did not create the group, or has since left
/// and rejoined.
public func fetchRevisionZeroGroupChangeAction(secretParams: GroupSecretParams) async throws -> GroupV2Change {
let groupV2Params = try GroupV2Params(groupSecretParams: secretParams)
let groupId = try groupV2Params.groupPublicParams.getGroupIdentifier().serialize()
let gseExpiration: UInt64
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
gseExpiration = databaseStorage.read { tx in
let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: tx)
let groupThreadId = groupThread?.sqliteRowId!
let endorsementRecord = groupThreadId.flatMap({ try? groupSendEndorsementStore.fetchCombinedEndorsement(groupThreadId: $0, tx: tx) })
return endorsementRecord?.expirationTimestamp ?? 0
}
let groupChangesResponse = try await _fetchSomeGroupChangeActions(
secretParams: secretParams,
startingAtRevision: 0,
upThroughRevision: 0,
includeFirstState: true,
gseExpiration: gseExpiration,
)
guard let first = groupChangesResponse.groupChanges.first else {
throw OWSAssertionError("Missing first group change action")
}
return first
}
/// Fetches some group changes (and a snapshot, if needed).
public func fetchSomeGroupChangeActions(
secretParams: GroupSecretParams,

View File

@ -18,6 +18,9 @@ public struct ChangedGroupModel {
/// group change.
public let newlyLearnedPniToAciAssociations: [Pni: Aci]
/// Whether we should update the last verified name hash because the local user changed it.
public let shouldUpdateLastVerifiedGroupNameHash: Bool
public init(
oldGroupModel: TSGroupModelV2,
newGroupModel: TSGroupModelV2,
@ -25,6 +28,7 @@ public struct ChangedGroupModel {
updateSource: GroupUpdateSource,
profileKeys: [Aci: Data],
newlyLearnedPniToAciAssociations: [Pni: Aci],
shouldUpdateLastVerifiedGroupNameHash: Bool,
) {
self.oldGroupModel = oldGroupModel
self.newGroupModel = newGroupModel
@ -32,6 +36,7 @@ public struct ChangedGroupModel {
self.updateSource = updateSource
self.profileKeys = profileKeys
self.newlyLearnedPniToAciAssociations = newlyLearnedPniToAciAssociations
self.shouldUpdateLastVerifiedGroupNameHash = shouldUpdateLastVerifiedGroupNameHash
}
}
@ -533,6 +538,7 @@ public class GroupsV2IncomingChanges {
groupMembershipBuilder.removeBannedMember(aci)
}
var shouldUpdateLastVerifiedGroupNameHash = false
if let action = changeActionsProto.modifyTitle {
if !canEditAttributes {
owsFailDebug("Cannot modify title.")
@ -540,6 +546,10 @@ public class GroupsV2IncomingChanges {
// Change clears or updates the group title.
newGroupName = groupV2Params.decryptGroupName(action.title)
if changeAuthor == localIdentifiers.aci {
shouldUpdateLastVerifiedGroupNameHash = true
}
}
if let action = changeActionsProto.modifyDescription {
@ -687,6 +697,7 @@ public class GroupsV2IncomingChanges {
updateSource: updateSource,
profileKeys: profileKeys,
newlyLearnedPniToAciAssociations: newlyLearnedPniToAciAssociations,
shouldUpdateLastVerifiedGroupNameHash: shouldUpdateLastVerifiedGroupNameHash,
)
}
}

View File

@ -87,7 +87,10 @@ public struct TSGroupModelBuilder {
self.isTerminated = groupV2Snapshot.isTerminated
}
static func builderForSnapshot(groupV2Snapshot: GroupV2Snapshot, transaction: DBWriteTransaction) throws -> TSGroupModelBuilder {
static func builderForSnapshot(
groupV2Snapshot: GroupV2Snapshot,
transaction: DBWriteTransaction,
) throws -> TSGroupModelBuilder {
return try TSGroupModelBuilder(groupV2Snapshot: groupV2Snapshot)
}

View File

@ -638,6 +638,9 @@ struct StorageServiceProtos_GroupV2Record: Sendable {
/// Clears the value of `avatarColor`. Subsequent reads from it will return its default value.
mutating func clearAvatarColor() {self._avatarColor = nil}
/// SHA-256 of last verified group name
var verifiedNameHash: Data = Data()
var unknownFields = SwiftProtobuf.UnknownStorage()
enum StorySendMode: SwiftProtobuf.Enum, Swift.CaseIterable {
@ -1901,7 +1904,7 @@ extension StorageServiceProtos_GroupV1Record: SwiftProtobuf.Message, SwiftProtob
extension StorageServiceProtos_GroupV2Record: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".GroupV2Record"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}masterKey\0\u{1}blocked\0\u{1}whitelisted\0\u{1}archived\0\u{1}markedUnread\0\u{1}mutedUntilTimestamp\0\u{1}dontNotifyForMentionsIfMuted\0\u{1}hideStory\0\u{2}\u{2}storySendMode\0\u{1}avatarColor\0")
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}masterKey\0\u{1}blocked\0\u{1}whitelisted\0\u{1}archived\0\u{1}markedUnread\0\u{1}mutedUntilTimestamp\0\u{1}dontNotifyForMentionsIfMuted\0\u{1}hideStory\0\u{2}\u{2}storySendMode\0\u{1}avatarColor\0\u{1}verifiedNameHash\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
@ -1919,6 +1922,7 @@ extension StorageServiceProtos_GroupV2Record: SwiftProtobuf.Message, SwiftProtob
case 8: try { try decoder.decodeSingularBoolField(value: &self.hideStory) }()
case 10: try { try decoder.decodeSingularEnumField(value: &self.storySendMode) }()
case 11: try { try decoder.decodeSingularEnumField(value: &self._avatarColor) }()
case 12: try { try decoder.decodeSingularBytesField(value: &self.verifiedNameHash) }()
default: break
}
}
@ -1959,6 +1963,9 @@ extension StorageServiceProtos_GroupV2Record: SwiftProtobuf.Message, SwiftProtob
try { if let v = self._avatarColor {
try visitor.visitSingularEnumField(value: v, fieldNumber: 11)
} }()
if !self.verifiedNameHash.isEmpty {
try visitor.visitSingularBytesField(value: self.verifiedNameHash, fieldNumber: 12)
}
try unknownFields.traverse(visitor: &visitor)
}
@ -1973,6 +1980,7 @@ extension StorageServiceProtos_GroupV2Record: SwiftProtobuf.Message, SwiftProtob
if lhs.hideStory != rhs.hideStory {return false}
if lhs.storySendMode != rhs.storySendMode {return false}
if lhs._avatarColor != rhs._avatarColor {return false}
if lhs.verifiedNameHash != rhs.verifiedNameHash {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}

View File

@ -2258,6 +2258,16 @@ public struct StorageServiceProtoGroupV2Record: Codable, CustomDebugStringConver
return proto.hasAvatarColor
}
public var verifiedNameHash: Data? {
guard hasVerifiedNameHash else {
return nil
}
return proto.verifiedNameHash
}
public var hasVerifiedNameHash: Bool {
return !proto.verifiedNameHash.isEmpty
}
public var hasUnknownFields: Bool {
return !proto.unknownFields.data.isEmpty
}
@ -2322,6 +2332,9 @@ extension StorageServiceProtoGroupV2Record {
if let _value = avatarColor {
builder.setAvatarColor(_value)
}
if let _value = verifiedNameHash {
builder.setVerifiedNameHash(_value)
}
if let _value = unknownFields {
builder.setUnknownFields(_value)
}
@ -2386,6 +2399,16 @@ public struct StorageServiceProtoGroupV2RecordBuilder {
proto.avatarColor = StorageServiceProtoAvatarColorUnwrap(valueParam)
}
@available(swift, obsoleted: 1.0)
public mutating func setVerifiedNameHash(_ valueParam: Data?) {
guard let valueParam = valueParam else { return }
proto.verifiedNameHash = valueParam
}
public mutating func setVerifiedNameHash(_ valueParam: Data) {
proto.verifiedNameHash = valueParam
}
public mutating func setUnknownFields(_ unknownFields: SwiftProtobuf.UnknownStorage) {
proto.unknownFields = unknownFields
}

View File

@ -171,6 +171,7 @@ message GroupV2Record {
bool hideStory = 8;
StorySendMode storySendMode = 10;
optional AvatarColor avatarColor = 11;
bytes verifiedNameHash = 12; // SHA-256 of last verified group name
}
message AccountRecord {

View File

@ -324,6 +324,7 @@ public class GRDBSchemaMigrator {
case addDevice
case addAttachmentBackfillRequestTable
case wipeBackupAttachmentUploadQueueForLinkedDevices
case addLastVerifiedGroupNameHashColumn
// NOTE: Every time we add a migration id, consider
// incrementing grdbSchemaVersionLatest.
@ -5075,6 +5076,13 @@ public class GRDBSchemaMigrator {
return .success(())
}
migrator.registerMigration(.addLastVerifiedGroupNameHashColumn) { transaction in
try transaction.database.alter(table: "thread_associated_data") { table in
table.add(column: "lastVerifiedGroupNameHash", .blob)
}
return .success(())
}
// MARK: - Schema Migration Insertion Point
}
@ -5261,8 +5269,10 @@ public class GRDBSchemaMigrator {
isArchived: thread.isArchivedObsolete,
isMarkedUnread: thread.isMarkedUnreadObsolete,
mutedUntilTimestamp: thread.mutedUntilTimestampObsolete,
// this didn't exist pre-migration, just write the default
// audioPlaybackRate and lastVerifiedGroupNameHash didn't exist pre-migration,
// just write the default
audioPlaybackRate: 1,
lastVerifiedGroupNameHash: nil,
).insert(transaction.database)
} catch {
thrownError = error

View File

@ -988,6 +988,10 @@ class StorageServiceGroupV2RecordUpdater: StorageServiceRecordUpdater {
builder.setMarkedUnread(threadAssociatedData.isMarkedUnread)
builder.setMutedUntilTimestamp(threadAssociatedData.mutedUntilTimestamp)
if let lastVerifiedGroupNameHash = threadAssociatedData.lastVerifiedGroupNameHash {
builder.setVerifiedNameHash(lastVerifiedGroupNameHash)
}
let groupThread = TSGroupThread.fetch(forGroupId: groupId, tx: transaction)
switch groupThread?.mentionNotificationMode {
case .none, .default:
@ -1129,6 +1133,10 @@ class StorageServiceGroupV2RecordUpdater: StorageServiceRecordUpdater {
needsUpdate = true
}
if let verifiedHash = record.verifiedNameHash, verifiedHash != localThreadAssociatedData.lastVerifiedGroupNameHash {
localThreadAssociatedData.updateWith(lastVerifiedGroupNameHash: verifiedHash, updateStorageService: false, transaction: transaction)
}
return .merged(needsUpdate: needsUpdate, masterKey)
}

View File

@ -70,6 +70,7 @@ class PhoneNumberChangedMessageInserterTest: XCTestCase {
isMarkedUnread: false,
mutedUntilTimestamp: 0,
audioPlaybackRate: 1,
lastVerifiedGroupNameHash: nil,
)
let interactionStore = MockInteractionStore()

View File

@ -203,6 +203,7 @@ final class ThreadMergerTest: XCTestCase {
isMarkedUnread: isMarkedUnread,
mutedUntilTimestamp: mutedUntilTimestamp,
audioPlaybackRate: audioPlaybackRate,
lastVerifiedGroupNameHash: nil,
)
}

View File

@ -58,6 +58,7 @@ class ThreadFinderTests: XCTestCase {
isMarkedUnread: isMarkedUnread,
mutedUntilTimestamp: 0,
audioPlaybackRate: 1,
lastVerifiedGroupNameHash: nil,
)
}