2016 lines
88 KiB
Swift
2016 lines
88 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import LibSignalClient
|
|
import SignalRingRTC
|
|
import SwiftProtobuf
|
|
|
|
// MARK: - Record Updater Protocol
|
|
|
|
protocol StorageServiceRecordUpdater {
|
|
associatedtype IdType
|
|
associatedtype RecordType
|
|
|
|
func unknownFields(for record: RecordType) -> UnknownStorage?
|
|
|
|
/// Creates a Record that can be put in Storage Service.
|
|
///
|
|
/// - Parameter localId: The unique identifier of the item being built.
|
|
///
|
|
/// - Parameter unknownFields: Any unknown fields already present for the
|
|
/// item with this identifier. If there's a value, that value should be
|
|
/// transferred to the result.
|
|
///
|
|
/// - Parameter transaction: A database transaction.
|
|
///
|
|
/// - Returns: A record with the values for the item identified by
|
|
/// `localId`. If `localId` doesn't exist, or if `localId` isn't valid,
|
|
/// `nil` is returned. Callers should exclude items which return `nil`.
|
|
func buildRecord(
|
|
for localId: IdType,
|
|
unknownFields: UnknownStorage?,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> RecordType?
|
|
|
|
func buildStorageItem(for record: RecordType) -> StorageService.StorageItem
|
|
|
|
/// Updates local device state to match a Record from Storage Service.
|
|
///
|
|
/// Our general merge philosophy is that the latest value on the service is
|
|
/// always right. There are some edge cases where this could cause user
|
|
/// changes to get blown away, such as if you're changing values
|
|
/// simultaneously on two devices or if you force quit the application
|
|
/// before it has had a chance to sync. To mitigate these issues, we push
|
|
/// changes quickly when they're made (because changes are infrequent).
|
|
///
|
|
/// If this is unreliable, we could maintain timestamps representing the
|
|
/// remote and local update time for every value we sync. For now, we'd like
|
|
/// to avoid that as it adds its own set of problems.
|
|
///
|
|
/// - Parameter record: The record that should be merged.
|
|
///
|
|
/// - Parameter transaction: A database transaction.
|
|
///
|
|
/// - Returns: A type indicating the result of the merge.
|
|
func mergeRecord(
|
|
_ record: RecordType,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<IdType>
|
|
}
|
|
|
|
enum StorageServiceMergeResult<IdType> {
|
|
/// The merge couldn't be completed because the record is malformed. This
|
|
/// happens most often when the record doesn't have an identifier. For
|
|
/// example, if there's a group record that doesn't specify the group to
|
|
/// which it pertains, it's invalid and should be deleted.
|
|
case invalid
|
|
|
|
/// The merge completed successfully. The first associated value indicates
|
|
/// whether or not there are changes on the local device that should be
|
|
/// synced. The second associated value indicates the identifier for the
|
|
/// item that was merged.
|
|
case merged(needsUpdate: Bool, IdType)
|
|
}
|
|
|
|
// MARK: - Contact Record
|
|
|
|
struct StorageServiceContact {
|
|
/// Contact records must have at least an ACI or a PNI.
|
|
let serviceIds: AtLeastOneServiceId
|
|
|
|
var aci: Aci? { serviceIds.aci }
|
|
var pni: Pni? { serviceIds.pni }
|
|
|
|
/// Contact records may have a phone number.
|
|
let phoneNumber: E164?
|
|
|
|
/// Contact records may be unregistered.
|
|
let unregisteredAtTimestamp: UInt64?
|
|
|
|
init?(aci: Aci?, phoneNumber: E164?, pni: Pni?, unregisteredAtTimestamp: UInt64?) {
|
|
guard let serviceIds = AtLeastOneServiceId(aci: aci, pni: pni) else {
|
|
return nil
|
|
}
|
|
self.serviceIds = serviceIds
|
|
self.phoneNumber = phoneNumber
|
|
self.unregisteredAtTimestamp = unregisteredAtTimestamp
|
|
}
|
|
|
|
enum RegistrationStatus {
|
|
case registered
|
|
case unregisteredRecently
|
|
case unregisteredAWhileAgo
|
|
}
|
|
|
|
func registrationStatus(currentDate: Date, remoteConfig: RemoteConfig) -> RegistrationStatus {
|
|
switch unregisteredAtTimestamp {
|
|
case .none:
|
|
return .registered
|
|
|
|
case .some(let timestamp) where currentDate.timeIntervalSince(Date(millisecondsSince1970: timestamp)) <= remoteConfig.messageQueueTime:
|
|
return .unregisteredRecently
|
|
|
|
case .some:
|
|
return .unregisteredAWhileAgo
|
|
}
|
|
}
|
|
|
|
fileprivate init?(_ contactRecord: StorageServiceProtoContactRecord) {
|
|
let unregisteredAtTimestamp: UInt64?
|
|
if contactRecord.unregisteredAtTimestamp == 0 {
|
|
unregisteredAtTimestamp = nil // registered
|
|
} else {
|
|
unregisteredAtTimestamp = contactRecord.unregisteredAtTimestamp
|
|
}
|
|
self.init(
|
|
aci: contactRecord.aci.flatMap { Aci.parseFrom(aciString: $0) },
|
|
phoneNumber: E164.expectNilOrValid(stringValue: contactRecord.e164),
|
|
pni: contactRecord.pni.flatMap { Pni.parseFrom(pniString: $0) },
|
|
unregisteredAtTimestamp: unregisteredAtTimestamp
|
|
)
|
|
}
|
|
|
|
static func fetch(for recipientUniqueId: RecipientUniqueId, tx: SDSAnyReadTransaction) -> Self? {
|
|
SignalRecipient.anyFetch(uniqueId: recipientUniqueId, transaction: tx).flatMap { Self($0) }
|
|
}
|
|
|
|
fileprivate init?(_ signalRecipient: SignalRecipient) {
|
|
let unregisteredAtTimestamp: UInt64?
|
|
if signalRecipient.isRegistered {
|
|
unregisteredAtTimestamp = nil
|
|
} else {
|
|
unregisteredAtTimestamp = (
|
|
signalRecipient.unregisteredAtTimestamp ?? SignalRecipient.Constants.distantPastUnregisteredTimestamp
|
|
)
|
|
}
|
|
self.init(
|
|
aci: signalRecipient.aci,
|
|
phoneNumber: E164.expectNilOrValid(stringValue: signalRecipient.phoneNumber?.stringValue),
|
|
pni: signalRecipient.pni,
|
|
unregisteredAtTimestamp: unregisteredAtTimestamp
|
|
)
|
|
}
|
|
|
|
func shouldBeInStorageService(currentDate: Date, remoteConfig: RemoteConfig) -> Bool {
|
|
switch registrationStatus(currentDate: currentDate, remoteConfig: remoteConfig) {
|
|
case .registered, .unregisteredRecently:
|
|
return true
|
|
case .unregisteredAWhileAgo:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func matchesAnyLocalIdentifier(in localIdentifiers: LocalIdentifiers) -> Bool {
|
|
return localIdentifiers.containsAnyOf(aci: aci, phoneNumber: phoneNumber, pni: pni)
|
|
}
|
|
}
|
|
|
|
class StorageServiceContactRecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = RecipientUniqueId
|
|
typealias RecordType = StorageServiceProtoContactRecord
|
|
|
|
private let localIdentifiers: LocalIdentifiers
|
|
private let isPrimaryDevice: Bool
|
|
private let authedAccount: AuthedAccount
|
|
private let blockingManager: BlockingManager
|
|
private let contactsManager: OWSContactsManager
|
|
private let identityManager: OWSIdentityManager
|
|
private let nicknameManager: NicknameManager
|
|
private let profileFetcher: any ProfileFetcher
|
|
private let profileManager: OWSProfileManager
|
|
private let recipientManager: any SignalRecipientManager
|
|
private let recipientMerger: RecipientMerger
|
|
private let recipientHidingManager: RecipientHidingManager
|
|
private let remoteConfigProvider: any RemoteConfigProvider
|
|
private let signalServiceAddressCache: SignalServiceAddressCache
|
|
private let tsAccountManager: TSAccountManager
|
|
private let usernameLookupManager: UsernameLookupManager
|
|
|
|
init(
|
|
localIdentifiers: LocalIdentifiers,
|
|
isPrimaryDevice: Bool,
|
|
authedAccount: AuthedAccount,
|
|
blockingManager: BlockingManager,
|
|
contactsManager: OWSContactsManager,
|
|
identityManager: OWSIdentityManager,
|
|
nicknameManager: NicknameManager,
|
|
profileFetcher: ProfileFetcher,
|
|
profileManager: OWSProfileManager,
|
|
recipientManager: any SignalRecipientManager,
|
|
recipientMerger: RecipientMerger,
|
|
recipientHidingManager: RecipientHidingManager,
|
|
remoteConfigProvider: any RemoteConfigProvider,
|
|
signalServiceAddressCache: SignalServiceAddressCache,
|
|
tsAccountManager: TSAccountManager,
|
|
usernameLookupManager: UsernameLookupManager
|
|
) {
|
|
self.localIdentifiers = localIdentifiers
|
|
self.isPrimaryDevice = isPrimaryDevice
|
|
self.authedAccount = authedAccount
|
|
self.blockingManager = blockingManager
|
|
self.contactsManager = contactsManager
|
|
self.identityManager = identityManager
|
|
self.nicknameManager = nicknameManager
|
|
self.profileFetcher = profileFetcher
|
|
self.profileManager = profileManager
|
|
self.recipientManager = recipientManager
|
|
self.recipientMerger = recipientMerger
|
|
self.recipientHidingManager = recipientHidingManager
|
|
self.remoteConfigProvider = remoteConfigProvider
|
|
self.signalServiceAddressCache = signalServiceAddressCache
|
|
self.tsAccountManager = tsAccountManager
|
|
self.usernameLookupManager = usernameLookupManager
|
|
}
|
|
|
|
func unknownFields(for record: StorageServiceProtoContactRecord) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildRecord(
|
|
for recipientUniqueId: RecipientUniqueId,
|
|
unknownFields: UnknownStorage?,
|
|
transaction tx: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoContactRecord? {
|
|
guard let recipient = SignalRecipient.anyFetch(uniqueId: recipientUniqueId, transaction: tx) else {
|
|
return nil
|
|
}
|
|
|
|
guard let contact = StorageServiceContact(recipient) else {
|
|
return nil
|
|
}
|
|
|
|
if contact.matchesAnyLocalIdentifier(in: localIdentifiers) {
|
|
owsFailDebug("Can't create contact with any local identifier")
|
|
return nil
|
|
}
|
|
|
|
guard contact.shouldBeInStorageService(currentDate: Date(), remoteConfig: remoteConfigProvider.currentConfig()) else {
|
|
return nil
|
|
}
|
|
|
|
var builder = StorageServiceProtoContactRecord.builder()
|
|
|
|
/// Helps determine if a username is the best identifier we have for
|
|
/// this address.
|
|
var usernameBetterIdentifierChecker = Usernames.BetterIdentifierChecker(forRecipient: recipient)
|
|
|
|
if let aci = contact.aci {
|
|
builder.setAci(aci.serviceIdString)
|
|
}
|
|
if let phoneNumber = contact.phoneNumber {
|
|
builder.setE164(phoneNumber.stringValue)
|
|
usernameBetterIdentifierChecker.add(e164: phoneNumber.stringValue)
|
|
}
|
|
if let pni = contact.pni {
|
|
builder.setPni(pni.rawUUID.uuidString.lowercased())
|
|
}
|
|
|
|
if let unregisteredAtTimestamp = contact.unregisteredAtTimestamp {
|
|
builder.setUnregisteredAtTimestamp(unregisteredAtTimestamp)
|
|
}
|
|
|
|
// This could be an ACI or a PNI address.
|
|
let anyAddress = SignalServiceAddress(contact.serviceIds.aciOrElsePni)
|
|
|
|
let isInWhitelist = profileManager.isUser(inProfileWhitelist: anyAddress, transaction: tx)
|
|
builder.setWhitelisted(isInWhitelist)
|
|
|
|
builder.setBlocked(blockingManager.isAddressBlocked(anyAddress, transaction: tx))
|
|
builder.setHidden(recipientHidingManager.isHiddenAddress(anyAddress, tx: tx.asV2Read))
|
|
|
|
// Identity
|
|
|
|
if let identityKey = try? identityManager.identityKey(for: contact.serviceIds.aciOrElsePni, tx: tx.asV2Read) {
|
|
builder.setIdentityKey(identityKey.serialize().asData)
|
|
}
|
|
|
|
let verificationState = identityManager.verificationState(for: anyAddress, tx: tx.asV2Read)
|
|
builder.setIdentityState(.from(verificationState))
|
|
|
|
// Profile
|
|
|
|
let profileKey = profileManager.profileKeyData(for: anyAddress, transaction: tx)
|
|
let profileGivenName = profileManager.unfilteredGivenName(for: anyAddress, transaction: tx)
|
|
let profileFamilyName = profileManager.unfilteredFamilyName(for: anyAddress, transaction: tx)
|
|
|
|
if let profileKey = profileKey {
|
|
builder.setProfileKey(profileKey)
|
|
}
|
|
|
|
if let profileGivenName = profileGivenName {
|
|
builder.setGivenName(profileGivenName)
|
|
usernameBetterIdentifierChecker.add(profileGivenName: profileGivenName)
|
|
}
|
|
|
|
if let profileFamilyName = profileFamilyName {
|
|
builder.setFamilyName(profileFamilyName)
|
|
usernameBetterIdentifierChecker.add(profileFamilyName: profileFamilyName)
|
|
}
|
|
|
|
let systemContact = { () -> SignalAccount? in
|
|
guard let phoneNumber = contact.phoneNumber else {
|
|
return nil
|
|
}
|
|
return contactsManager.fetchSignalAccount(
|
|
forPhoneNumber: phoneNumber.stringValue,
|
|
transaction: tx
|
|
)
|
|
}()
|
|
|
|
if let systemContact {
|
|
// We have a contact for this address, whose name we may want to
|
|
// add to this ContactRecord. We should add it if:
|
|
//
|
|
// - We are a primary device, and this contact is from our local
|
|
// address book. In this case, we want to let linked devices
|
|
// know about our "system contact".
|
|
//
|
|
// - We are a linked device, and this is a contact we synced from
|
|
// the primary device (via a previous ContactRecord). In this
|
|
// case, we want to preserve the name the primary device
|
|
// originally uploaded.
|
|
|
|
let isPrimary = isPrimaryDevice
|
|
let isPrimaryAndHasLocalContact = isPrimary && systemContact.isFromLocalAddressBook
|
|
let isLinkedAndHasSyncedContact = !isPrimary && !systemContact.isFromLocalAddressBook
|
|
|
|
if isPrimaryAndHasLocalContact || isLinkedAndHasSyncedContact {
|
|
let systemGivenName = systemContact.givenName
|
|
builder.setSystemGivenName(systemGivenName)
|
|
usernameBetterIdentifierChecker.add(systemContactGivenName: systemGivenName)
|
|
|
|
let systemFamilyName = systemContact.familyName
|
|
builder.setSystemFamilyName(systemFamilyName)
|
|
usernameBetterIdentifierChecker.add(systemContactFamilyName: systemFamilyName)
|
|
|
|
let systemNickname = systemContact.nickname
|
|
builder.setSystemNickname(systemNickname)
|
|
usernameBetterIdentifierChecker.add(systemContactNickname: systemNickname)
|
|
}
|
|
}
|
|
|
|
if let thread = TSContactThread.getWithContactAddress(anyAddress, transaction: tx) {
|
|
let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: thread, transaction: tx)
|
|
|
|
builder.setArchived(threadAssociatedData.isArchived)
|
|
builder.setMarkedUnread(threadAssociatedData.isMarkedUnread)
|
|
builder.setMutedUntilTimestamp(threadAssociatedData.mutedUntilTimestamp)
|
|
}
|
|
|
|
if let aci = contact.aci, let associatedData = StoryFinder.getAssociatedData(forAci: aci, tx: tx) {
|
|
builder.setHideStory(associatedData.isHidden)
|
|
}
|
|
|
|
// Username
|
|
|
|
let username: String? = {
|
|
// Only add a username to the ContactRecord if we have no other identifiers
|
|
// to display.
|
|
guard let aci = contact.aci, usernameBetterIdentifierChecker.usernameIsBestIdentifier() else {
|
|
return nil
|
|
}
|
|
return usernameLookupManager.fetchUsername(forAci: aci, transaction: tx.asV2Read)
|
|
}()
|
|
if let username {
|
|
builder.setUsername(username)
|
|
}
|
|
|
|
// Nickname/note
|
|
|
|
if let nicknameRecord = nicknameManager.fetchNickname(for: recipient, tx: tx.asV2Read) {
|
|
var nicknameBuilder = StorageServiceProtoContactRecordName.builder()
|
|
nicknameRecord.givenName.map { nicknameBuilder.setGiven($0) }
|
|
nicknameRecord.familyName.map { nicknameBuilder.setFamily($0) }
|
|
builder.setNickname(nicknameBuilder.buildInfallibly())
|
|
nicknameRecord.note.map { builder.setNote($0) }
|
|
}
|
|
|
|
// Unknown
|
|
|
|
if let unknownFields = unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoContactRecord) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .contact), contact: record)
|
|
}
|
|
|
|
static func shouldDeferMerge(_ record: StorageServiceProtoContactRecord) -> Bool {
|
|
return StorageServiceContact(record)?.aci == nil
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoContactRecord,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<RecipientUniqueId> {
|
|
guard let contact = StorageServiceContact(record) else {
|
|
owsFailDebug("Can't merge record with invalid identifiers: hasAci? \(record.hasAci) hasPni? \(record.hasPni) hasPhoneNumber? \(record.hasE164)")
|
|
return .invalid
|
|
}
|
|
|
|
if contact.matchesAnyLocalIdentifier(in: localIdentifiers) {
|
|
owsFailDebug("Can't merge record for the local user") // this should be an AccountRecord
|
|
return .invalid
|
|
}
|
|
|
|
let recipient = recipientMerger.applyMergeFromStorageService(
|
|
localIdentifiers: localIdentifiers,
|
|
isPrimaryDevice: isPrimaryDevice,
|
|
serviceIds: contact.serviceIds,
|
|
phoneNumber: contact.phoneNumber,
|
|
tx: transaction.asV2Write
|
|
)
|
|
if let unregisteredAtTimestamp = contact.unregisteredAtTimestamp {
|
|
recipientManager.markAsUnregisteredAndSave(
|
|
recipient,
|
|
unregisteredAt: .specificTimeFromOtherDevice(unregisteredAtTimestamp),
|
|
shouldUpdateStorageService: false,
|
|
tx: transaction.asV2Write
|
|
)
|
|
// For Storage Service, we only perform contact splitting if it's an
|
|
// ACI-only recipient. The recipient returned from
|
|
// `applyMergeFromStorageService` will have our local state, so we
|
|
// explicitly check the remote state here.
|
|
if contact.phoneNumber == nil, contact.pni == nil {
|
|
recipientMerger.splitUnregisteredRecipientIfNeeded(
|
|
localIdentifiers: localIdentifiers,
|
|
unregisteredRecipient: recipient,
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
} else {
|
|
recipientManager.markAsRegisteredAndSave(
|
|
recipient,
|
|
shouldUpdateStorageService: false,
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
guard let serviceIds = AtLeastOneServiceId(aci: recipient.aci, pni: recipient.pni) else {
|
|
owsFailDebug("Can't have a merge result without a ServiceId")
|
|
return .invalid
|
|
}
|
|
|
|
return _mergeRecord(
|
|
record,
|
|
recipient: recipient,
|
|
serviceIds: serviceIds,
|
|
// If we merge and don't end up with what's in Storage Service, then it
|
|
// probably means that a linked device is wrong or we've hit a race
|
|
// condition where we learned something that's not yet reflected in Storage
|
|
// Service. When this happens, we should schedule an update to make sure
|
|
// Storage Service knows everything we know.
|
|
needsUpdate: (
|
|
recipient.aci != contact.aci
|
|
|| E164(recipient.phoneNumber?.stringValue) != contact.phoneNumber
|
|
|| recipient.pni != contact.pni
|
|
),
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
private func _mergeRecord(
|
|
_ record: StorageServiceProtoContactRecord,
|
|
recipient: SignalRecipient,
|
|
serviceIds: AtLeastOneServiceId,
|
|
needsUpdate: Bool,
|
|
tx: DBWriteTransaction
|
|
) -> StorageServiceMergeResult<RecipientUniqueId> {
|
|
var needsUpdate = needsUpdate
|
|
|
|
let anyAddress = SignalServiceAddress(serviceIds.aciOrElsePni)
|
|
|
|
// Gather some local contact state to do comparisons against.
|
|
let localIsBlocked = blockingManager.isAddressBlocked(anyAddress, transaction: SDSDB.shimOnlyBridge(tx))
|
|
let localIsHidden = recipientHidingManager.isHiddenAddress(anyAddress, tx: tx)
|
|
let localIsWhitelisted = profileManager.isUser(inProfileWhitelist: anyAddress, transaction: SDSDB.shimOnlyBridge(tx))
|
|
let localUserProfile = profileManager.getUserProfile(for: anyAddress, transaction: SDSDB.shimOnlyBridge(tx))
|
|
|
|
// If our local profile key record differs from what's on the service, use the service's value.
|
|
if let profileKey = record.profileKey, localUserProfile?.profileKey?.keyData != profileKey {
|
|
profileManager.setProfileKeyData(
|
|
profileKey,
|
|
for: serviceIds.aciOrElsePni,
|
|
onlyFillInIfMissing: false,
|
|
shouldFetchProfile: true,
|
|
userProfileWriter: .storageService,
|
|
localIdentifiers: localIdentifiers,
|
|
authedAccount: authedAccount,
|
|
tx: tx
|
|
)
|
|
|
|
// If we have a local profile key for this user but the service doesn't mark it as needing update.
|
|
} else if localUserProfile?.profileKey != nil && !record.hasProfileKey {
|
|
needsUpdate = true
|
|
}
|
|
|
|
// Given name can never be cleared, so ignore all info about the profile if
|
|
// there's no given name.
|
|
if record.hasGivenName && (localUserProfile?.givenName != record.givenName || localUserProfile?.familyName != record.familyName) {
|
|
// If we already have a profile for this user, ignore any content received
|
|
// via Storage Service. Instead, we'll just kick off a fetch of that user's
|
|
// profile to make sure everything is up-to-date.
|
|
if localUserProfile?.givenName != nil {
|
|
Task { [profileFetcher] in
|
|
_ = try? await profileFetcher.fetchProfile(for: serviceIds.aciOrElsePni, options: [.opportunistic])
|
|
}
|
|
} else {
|
|
let profileAddress = OWSUserProfile.insertableAddress(
|
|
serviceId: serviceIds.aciOrElsePni,
|
|
localIdentifiers: localIdentifiers
|
|
)
|
|
let localUserProfile = OWSUserProfile.getOrBuildUserProfile(
|
|
for: profileAddress,
|
|
userProfileWriter: .storageService,
|
|
tx: SDSDB.shimOnlyBridge(tx)
|
|
)
|
|
localUserProfile.update(
|
|
givenName: .setTo(record.givenName),
|
|
familyName: .setTo(record.familyName),
|
|
userProfileWriter: .storageService,
|
|
transaction: SDSDB.shimOnlyBridge(tx),
|
|
completion: nil
|
|
)
|
|
}
|
|
} else if localUserProfile?.givenName != nil && !record.hasGivenName || localUserProfile?.familyName != nil && !record.hasFamilyName {
|
|
needsUpdate = true
|
|
}
|
|
|
|
if mergeSystemContactNames(in: record, recipient: recipient, serviceIds: serviceIds, tx: tx) {
|
|
needsUpdate = true
|
|
}
|
|
|
|
// If our local identity differs from the service, use the service's value.
|
|
let localIdentityKey = try? identityManager.identityKey(for: serviceIds.aciOrElsePni, tx: tx)
|
|
if let identityKey = record.identityKey.flatMap({ try? IdentityKey(bytes: $0) }) {
|
|
if identityKey != localIdentityKey {
|
|
identityManager.saveIdentityKey(identityKey, for: serviceIds.aciOrElsePni, tx: tx)
|
|
}
|
|
// Make sure we fetch this after changing the identity key.
|
|
let identityState = record.identityState.verificationState
|
|
let localIdentityState = identityManager.verificationState(for: anyAddress, tx: tx)
|
|
if identityState != localIdentityState {
|
|
_ = identityManager.setVerificationState(
|
|
identityState,
|
|
of: identityKey.publicKey.keyBytes.asData,
|
|
for: anyAddress,
|
|
isUserInitiatedChange: false,
|
|
tx: tx
|
|
)
|
|
}
|
|
}
|
|
// If we have a local identity for this user but the service doesn't, mark it as needing update.
|
|
if localIdentityKey != nil && !record.hasIdentityKey {
|
|
needsUpdate = true
|
|
}
|
|
|
|
// If our local blocked state differs from the service state, use the service's value.
|
|
if record.blocked != localIsBlocked {
|
|
if record.blocked {
|
|
blockingManager.addBlockedAddress(anyAddress, blockMode: .remote, transaction: SDSDB.shimOnlyBridge(tx))
|
|
} else {
|
|
blockingManager.removeBlockedAddress(anyAddress, wasLocallyInitiated: false, transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
}
|
|
|
|
// If our local hidden state differs from the service state, use the service's value.
|
|
if record.hidden != localIsHidden {
|
|
if record.hidden {
|
|
do {
|
|
try recipientHidingManager.addHiddenRecipient(
|
|
anyAddress,
|
|
inKnownMessageRequestState: false,
|
|
wasLocallyInitiated: false,
|
|
tx: tx
|
|
)
|
|
} catch {
|
|
Logger.warn("Recipient hidden remotely could not be hidden locally.")
|
|
}
|
|
} else {
|
|
do {
|
|
try recipientHidingManager.removeHiddenRecipient(anyAddress, wasLocallyInitiated: false, tx: tx)
|
|
} catch {
|
|
Logger.warn("Recipient hidden remotely could not be unhidden locally.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// If our local whitelisted state differs from the service state, use the service's value.
|
|
if record.whitelisted != localIsWhitelisted {
|
|
if record.whitelisted {
|
|
profileManager.addUser(
|
|
toProfileWhitelist: anyAddress,
|
|
userProfileWriter: .storageService,
|
|
transaction: SDSDB.shimOnlyBridge(tx)
|
|
)
|
|
} else {
|
|
profileManager.removeUser(
|
|
fromProfileWhitelist: anyAddress,
|
|
userProfileWriter: .storageService,
|
|
transaction: SDSDB.shimOnlyBridge(tx)
|
|
)
|
|
}
|
|
}
|
|
|
|
let localThread = TSContactThread.getOrCreateThread(withContactAddress: anyAddress, transaction: SDSDB.shimOnlyBridge(tx))
|
|
let localThreadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: localThread, transaction: SDSDB.shimOnlyBridge(tx))
|
|
|
|
if record.archived != localThreadAssociatedData.isArchived {
|
|
localThreadAssociatedData.updateWith(isArchived: record.archived, updateStorageService: false, transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
|
|
if record.markedUnread != localThreadAssociatedData.isMarkedUnread {
|
|
localThreadAssociatedData.updateWith(isMarkedUnread: record.markedUnread, updateStorageService: false, transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
|
|
if record.mutedUntilTimestamp != localThreadAssociatedData.mutedUntilTimestamp {
|
|
localThreadAssociatedData.updateWith(mutedUntilTimestamp: record.mutedUntilTimestamp, updateStorageService: false, transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
|
|
if let aci = serviceIds.aci {
|
|
let localStoryContextAssociatedData = StoryContextAssociatedData.fetchOrDefault(
|
|
sourceContext: .contact(contactAci: aci),
|
|
transaction: SDSDB.shimOnlyBridge(tx)
|
|
)
|
|
if record.hideStory != localStoryContextAssociatedData.isHidden {
|
|
localStoryContextAssociatedData.update(updateStorageService: false, isHidden: record.hideStory, transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
}
|
|
|
|
if let aci = serviceIds.aci {
|
|
let usernameIsBestIdentifierOnRecord: Bool = {
|
|
var betterIdentifierChecker = Usernames.BetterIdentifierChecker(forRecipient: recipient)
|
|
|
|
betterIdentifierChecker.add(e164: record.e164)
|
|
betterIdentifierChecker.add(profileGivenName: record.givenName)
|
|
betterIdentifierChecker.add(profileFamilyName: record.familyName)
|
|
betterIdentifierChecker.add(systemContactGivenName: record.systemGivenName)
|
|
betterIdentifierChecker.add(systemContactFamilyName: record.systemFamilyName)
|
|
betterIdentifierChecker.add(systemContactNickname: record.systemNickname)
|
|
|
|
return betterIdentifierChecker.usernameIsBestIdentifier()
|
|
}()
|
|
|
|
usernameLookupManager.saveUsername(
|
|
usernameIsBestIdentifierOnRecord ? record.username : nil,
|
|
forAci: aci,
|
|
transaction: tx
|
|
)
|
|
}
|
|
|
|
if
|
|
record.nickname?.hasGiven == true || record.nickname?.hasFamily == true || record.hasNote,
|
|
let nicknameRecord = NicknameRecord(
|
|
recipient: recipient,
|
|
givenName: record.nickname?.given,
|
|
familyName: record.nickname?.family,
|
|
note: record.note
|
|
)
|
|
{
|
|
nicknameManager.createOrUpdate(
|
|
nicknameRecord: nicknameRecord,
|
|
// Don't create a recursive Storage Service sync
|
|
updateStorageServiceFor: nil,
|
|
tx: tx
|
|
)
|
|
} else if let recipientRowID = recipient.id {
|
|
nicknameManager.deleteNickname(
|
|
recipientRowID: recipientRowID,
|
|
// Don't create a recursive Storage Service sync
|
|
updateStorageServiceFor: nil,
|
|
tx: tx
|
|
)
|
|
}
|
|
|
|
return .merged(needsUpdate: needsUpdate, recipient.uniqueId)
|
|
}
|
|
|
|
/// Merge system contact names from this ContactRecord with local state.
|
|
///
|
|
/// On primary devices, confirms that storage service has the correct
|
|
/// values. On linked devices, system contact data in this ContactRecord
|
|
/// will supercede any existing contact data for the given address.
|
|
///
|
|
/// - Returns: True if the record in StorageService should be updated. This
|
|
/// can happen on primary devices if StorageService has the wrong system
|
|
/// contact names.
|
|
private func mergeSystemContactNames(
|
|
in record: StorageServiceProtoContactRecord,
|
|
recipient: SignalRecipient,
|
|
serviceIds: AtLeastOneServiceId,
|
|
tx: DBWriteTransaction
|
|
) -> Bool {
|
|
// If there's no phone number, there's no system contact. If a phone number
|
|
// is removed, it'll be claimed by another account; if it's not claimed,
|
|
// the merging logic will delete the SignalAccount.
|
|
guard let phoneNumber = recipient.phoneNumber?.stringValue else {
|
|
return false
|
|
}
|
|
|
|
let localAccount = contactsManager.fetchSignalAccount(
|
|
forPhoneNumber: phoneNumber,
|
|
transaction: SDSDB.shimOnlyBridge(tx)
|
|
)
|
|
|
|
if isPrimaryDevice {
|
|
let localContact = localAccount?.isFromLocalAddressBook == true
|
|
let localSystemGivenName = localContact ? localAccount?.givenName : nil
|
|
let localSystemFamilyName = localContact ? localAccount?.familyName : nil
|
|
let localSystemNickname = localContact ? localAccount?.nickname : nil
|
|
// On the primary device, we should mark it as `needsUpdate` if it doesn't match the local state.
|
|
return (
|
|
localSystemGivenName != record.systemGivenName
|
|
|| localSystemFamilyName != record.systemFamilyName
|
|
|| localSystemNickname != record.systemNickname
|
|
)
|
|
}
|
|
|
|
// Otherwise, we should update the state on linked devices to match.
|
|
|
|
let newAccount: SignalAccount?
|
|
|
|
let systemFullName = Contact.fullName(
|
|
fromGivenName: record.systemGivenName,
|
|
familyName: record.systemFamilyName,
|
|
nickname: record.systemNickname
|
|
)
|
|
if let systemFullName {
|
|
// TODO: we should find a way to fill in `multipleAccountLabelText`.
|
|
// This is the string that helps disambiguate when multiple
|
|
// `SignalAccount`s are associated with the same system contact.
|
|
// For example, Alice may have a work and mobile number, both of
|
|
// of which are registered with Signal. This text could be (work)
|
|
// or (mobile), to help disambiguate - otherwise, both Signal
|
|
// accounts will present as just "Alice".
|
|
let multipleAccountLabelText = ""
|
|
|
|
newAccount = SignalAccount(
|
|
recipientPhoneNumber: phoneNumber,
|
|
recipientServiceId: serviceIds.aciOrElsePni,
|
|
multipleAccountLabelText: multipleAccountLabelText,
|
|
cnContactId: nil,
|
|
givenName: record.systemGivenName ?? "",
|
|
familyName: record.systemFamilyName ?? "",
|
|
nickname: record.systemNickname ?? "",
|
|
fullName: systemFullName,
|
|
contactAvatarHash: nil
|
|
)
|
|
} else {
|
|
newAccount = nil
|
|
}
|
|
|
|
switch (localAccount, newAccount) {
|
|
case (.some(let oldAccount), .some(let newAccount)) where oldAccount.hasSameContent(newAccount):
|
|
// What we've saved locally matches what Storage Service wants us to save.
|
|
// Don't make any changes.
|
|
break
|
|
|
|
default:
|
|
// We *might* have something locally, and there *might* be something in
|
|
// Storage Service. We should make them match, and we should notify about
|
|
// updates if we make any changes. If both are `nil`, we'll fall into this
|
|
// case and `didModifySignalAccount` will remain false.
|
|
var didModifySignalAccount = false
|
|
if let localAccount {
|
|
localAccount.anyRemove(transaction: SDSDB.shimOnlyBridge(tx))
|
|
didModifySignalAccount = true
|
|
}
|
|
if let newAccount {
|
|
newAccount.anyInsert(transaction: SDSDB.shimOnlyBridge(tx))
|
|
didModifySignalAccount = true
|
|
}
|
|
if didModifySignalAccount {
|
|
contactsManager.didUpdateSignalAccounts(transaction: SDSDB.shimOnlyBridge(tx))
|
|
}
|
|
let aciToUpdate = SignalAccount.aciForPhoneNumberVisibilityUpdate(
|
|
oldAccount: localAccount,
|
|
newAccount: newAccount
|
|
)
|
|
if aciToUpdate != nil {
|
|
// Tell the cache to refresh its state for this recipient. It will check
|
|
// whether or not the number should be visible based on this state and the
|
|
// state of system contacts.
|
|
signalServiceAddressCache.updateRecipient(recipient, tx: tx)
|
|
}
|
|
}
|
|
|
|
// We should never set `needsUpdates` from a linked device for system
|
|
// contact names. Linked devices should always update their local state to
|
|
// match Storage Service.
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension StorageServiceProtoContactRecordIdentityState {
|
|
static func from(_ state: VerificationState) -> StorageServiceProtoContactRecordIdentityState {
|
|
switch state {
|
|
case .verified:
|
|
return .verified
|
|
case .implicit(isAcknowledged: _):
|
|
return .default
|
|
case .noLongerVerified:
|
|
return .unverified
|
|
}
|
|
}
|
|
|
|
var verificationState: VerificationState {
|
|
switch self {
|
|
case .verified:
|
|
return .verified
|
|
case .default:
|
|
return .implicit(isAcknowledged: false)
|
|
case .unverified:
|
|
return .noLongerVerified
|
|
case .UNRECOGNIZED:
|
|
owsFailDebug("unrecognized verification state")
|
|
return .implicit(isAcknowledged: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Group V1 Record
|
|
|
|
/// A record updater for V1 groups that treats any contained fields as unknown.
|
|
///
|
|
/// We no longer rely on GroupV1 records from StorageService, as the groups they
|
|
/// correspond to are long-defunct. Consequently, this record updater simply
|
|
/// treats all fields in the record as unknown, thereby preserving fields any
|
|
/// older linked devices may still be parsing without using it ourselves.
|
|
///
|
|
/// 90 days after all clients are treating GroupV1 records as unknown, we can
|
|
/// stop re-uploading the unknown fields - thereby removing those records.
|
|
///
|
|
/// Eventually, if we no longer care about removing existing unused records, we
|
|
/// can remove the GroupV1 record from our protos entirely.
|
|
class StorageServiceGroupV1RecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = Data
|
|
typealias RecordType = StorageServiceProtoGroupV1Record
|
|
|
|
init() {}
|
|
|
|
func unknownFields(for record: StorageServiceProtoGroupV1Record) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoGroupV1Record) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .groupv1), groupV1: record)
|
|
}
|
|
|
|
func buildRecord(
|
|
for groupId: Data,
|
|
unknownFields: UnknownStorage?,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoGroupV1Record? {
|
|
var builder = StorageServiceProtoGroupV1Record.builder(id: groupId)
|
|
|
|
if let unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoGroupV1Record,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<Data> {
|
|
return .merged(needsUpdate: false, record.id)
|
|
}
|
|
}
|
|
|
|
// MARK: - Group V2 Record
|
|
|
|
class StorageServiceGroupV2RecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = Data
|
|
typealias RecordType = StorageServiceProtoGroupV2Record
|
|
|
|
private let authedAccount: AuthedAccount
|
|
private let blockingManager: BlockingManager
|
|
private let groupsV2: GroupsV2
|
|
private let profileManager: ProfileManager
|
|
|
|
init(
|
|
authedAccount: AuthedAccount,
|
|
blockingManager: BlockingManager,
|
|
groupsV2: GroupsV2,
|
|
profileManager: ProfileManager
|
|
) {
|
|
self.authedAccount = authedAccount
|
|
self.blockingManager = blockingManager
|
|
self.groupsV2 = groupsV2
|
|
self.profileManager = profileManager
|
|
}
|
|
|
|
func unknownFields(for record: StorageServiceProtoGroupV2Record) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoGroupV2Record) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .groupv2), groupV2: record)
|
|
}
|
|
|
|
func buildRecord(
|
|
for masterKeyData: Data,
|
|
unknownFields: UnknownStorage?,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoGroupV2Record? {
|
|
let groupContextInfo: GroupV2ContextInfo
|
|
do {
|
|
groupContextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: masterKeyData)
|
|
} catch {
|
|
owsFailDebug("Invalid master key \(error).")
|
|
return nil
|
|
}
|
|
|
|
let groupId = groupContextInfo.groupId
|
|
|
|
var builder = StorageServiceProtoGroupV2Record.builder(masterKey: masterKeyData)
|
|
|
|
builder.setWhitelisted(profileManager.isGroupId(inProfileWhitelist: groupId, transaction: transaction))
|
|
builder.setBlocked(blockingManager.isGroupIdBlocked(groupId, transaction: transaction))
|
|
|
|
let threadId = TSGroupThread.threadId(forGroupId: groupId, transaction: transaction)
|
|
let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: threadId,
|
|
ignoreMissing: true,
|
|
transaction: transaction)
|
|
|
|
builder.setArchived(threadAssociatedData.isArchived)
|
|
builder.setMarkedUnread(threadAssociatedData.isMarkedUnread)
|
|
builder.setMutedUntilTimestamp(threadAssociatedData.mutedUntilTimestamp)
|
|
|
|
let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction)
|
|
switch groupThread?.mentionNotificationMode {
|
|
case .none, .default:
|
|
break
|
|
case .never:
|
|
builder.setDontNotifyForMentionsIfMuted(true)
|
|
case .always:
|
|
builder.setDontNotifyForMentionsIfMuted(false)
|
|
}
|
|
|
|
if let storyContextAssociatedData = StoryFinder.getAssociatedData(forContext: .group(groupId: groupId), transaction: transaction) {
|
|
builder.setHideStory(storyContextAssociatedData.isHidden)
|
|
}
|
|
|
|
if let thread = TSGroupThread.anyFetchGroupThread(uniqueId: threadId, transaction: transaction) {
|
|
builder.setStorySendMode(thread.storyViewMode.storageServiceMode)
|
|
} else if let enqueuedRecord = groupsV2.groupRecordPendingStorageServiceRestore(
|
|
masterKeyData: masterKeyData,
|
|
transaction: transaction
|
|
) {
|
|
// We have a record pending restoration from storage service,
|
|
// preserve any of the data that we weren't able to restore
|
|
// yet because the thread record doesn't exist.
|
|
builder.setStorySendMode(enqueuedRecord.storySendMode)
|
|
}
|
|
|
|
if let unknownFields = unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoGroupV2Record,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<Data> {
|
|
let masterKey = record.masterKey
|
|
|
|
let groupContextInfo: GroupV2ContextInfo
|
|
do {
|
|
groupContextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: masterKey)
|
|
} catch {
|
|
owsFailDebug("Invalid master key.")
|
|
return .invalid
|
|
}
|
|
let groupId = groupContextInfo.groupId
|
|
|
|
if let localThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) {
|
|
let localStorySendMode = localThread.storyViewMode.storageServiceMode
|
|
if localStorySendMode != record.storySendMode {
|
|
localThread.updateWithStoryViewMode(.init(storageServiceMode: record.storySendMode), transaction: transaction)
|
|
}
|
|
|
|
// If the group thread doesn't exist, we will create it and reapply this update so the
|
|
// setting won't be lost. Note this isn't true for contact threads, only group threads,
|
|
// so TSContactThread metadata needs to live on ThreadAssociatedData so it can be saved
|
|
// even if the thread doesn't exist. But this field only applies to group threads, so
|
|
// no need.
|
|
switch (localThread.mentionNotificationMode, record.dontNotifyForMentionsIfMuted) {
|
|
case (.default, false), (.never, false):
|
|
localThread.updateWithMentionNotificationMode(.always, wasLocallyInitiated: false, transaction: transaction)
|
|
case (.default, true), (.always, true):
|
|
localThread.updateWithMentionNotificationMode(.never, wasLocallyInitiated: false, transaction: transaction)
|
|
case (.never, true), (.always, false):
|
|
// No change
|
|
break
|
|
}
|
|
} else {
|
|
groupsV2.restoreGroupFromStorageServiceIfNecessary(groupRecord: record, account: authedAccount, transaction: transaction)
|
|
}
|
|
|
|
// Gather some local contact state to do comparisons against.
|
|
let localIsBlocked = blockingManager.isGroupIdBlocked(groupId, transaction: transaction)
|
|
let localIsWhitelisted = profileManager.isGroupId(inProfileWhitelist: groupId, transaction: transaction)
|
|
|
|
// If our local blocked state differs from the service state, use the service's value.
|
|
if record.blocked != localIsBlocked {
|
|
if record.blocked {
|
|
blockingManager.addBlockedGroup(groupId: groupId, blockMode: .remote, transaction: transaction)
|
|
} else {
|
|
blockingManager.removeBlockedGroup(groupId: groupId, wasLocallyInitiated: false, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// If our local whitelisted state differs from the service state, use the service's value.
|
|
if record.whitelisted != localIsWhitelisted {
|
|
if record.whitelisted {
|
|
profileManager.addGroupId(toProfileWhitelist: groupId,
|
|
userProfileWriter: .storageService,
|
|
transaction: transaction)
|
|
} else {
|
|
profileManager.removeGroupId(fromProfileWhitelist: groupId,
|
|
userProfileWriter: .storageService,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
let localThreadId = TSGroupThread.threadId(forGroupId: groupId, transaction: transaction)
|
|
ThreadAssociatedData.create(for: localThreadId, transaction: transaction)
|
|
let localThreadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: localThreadId, transaction: transaction)
|
|
|
|
if record.archived != localThreadAssociatedData.isArchived {
|
|
localThreadAssociatedData.updateWith(isArchived: record.archived, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
if record.markedUnread != localThreadAssociatedData.isMarkedUnread {
|
|
localThreadAssociatedData.updateWith(isMarkedUnread: record.markedUnread, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
if record.mutedUntilTimestamp != localThreadAssociatedData.mutedUntilTimestamp {
|
|
localThreadAssociatedData.updateWith(mutedUntilTimestamp: record.mutedUntilTimestamp, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
let localStoryContextAssociatedData = StoryContextAssociatedData.fetchOrDefault(
|
|
sourceContext: .group(groupId: groupId),
|
|
transaction: transaction
|
|
)
|
|
if record.hideStory != localStoryContextAssociatedData.isHidden {
|
|
localStoryContextAssociatedData.update(updateStorageService: false, isHidden: record.hideStory, transaction: transaction)
|
|
}
|
|
|
|
return .merged(needsUpdate: false, masterKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - Account Record
|
|
|
|
class StorageServiceAccountRecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = Void
|
|
typealias RecordType = StorageServiceProtoAccountRecord
|
|
|
|
private let localIdentifiers: LocalIdentifiers
|
|
private let isPrimaryDevice: Bool
|
|
|
|
private let authedAccount: AuthedAccount
|
|
private let backupSubscriptionManager: BackupSubscriptionManager
|
|
private let dmConfigurationStore: DisappearingMessagesConfigurationStore
|
|
private let linkPreviewSettingStore: LinkPreviewSettingStore
|
|
private let localUsernameManager: LocalUsernameManager
|
|
private let paymentsHelper: PaymentsHelperSwift
|
|
private let phoneNumberDiscoverabilityManager: PhoneNumberDiscoverabilityManager
|
|
private let pinnedThreadManager: PinnedThreadManager
|
|
private let preferences: Preferences
|
|
private let profileManager: OWSProfileManager
|
|
private let receiptManager: OWSReceiptManager
|
|
private let registrationStateChangeManager: RegistrationStateChangeManager
|
|
private let storageServiceManager: StorageServiceManager
|
|
private let systemStoryManager: SystemStoryManagerProtocol
|
|
private let tsAccountManager: TSAccountManager
|
|
private let typingIndicators: TypingIndicators
|
|
private let udManager: OWSUDManager
|
|
private let usernameEducationManager: UsernameEducationManager
|
|
|
|
init(
|
|
localIdentifiers: LocalIdentifiers,
|
|
isPrimaryDevice: Bool,
|
|
authedAccount: AuthedAccount,
|
|
backupSubscriptionManager: BackupSubscriptionManager,
|
|
dmConfigurationStore: DisappearingMessagesConfigurationStore,
|
|
linkPreviewSettingStore: LinkPreviewSettingStore,
|
|
localUsernameManager: LocalUsernameManager,
|
|
paymentsHelper: PaymentsHelperSwift,
|
|
phoneNumberDiscoverabilityManager: PhoneNumberDiscoverabilityManager,
|
|
pinnedThreadManager: PinnedThreadManager,
|
|
preferences: Preferences,
|
|
profileManager: OWSProfileManager,
|
|
receiptManager: OWSReceiptManager,
|
|
registrationStateChangeManager: RegistrationStateChangeManager,
|
|
storageServiceManager: StorageServiceManager,
|
|
systemStoryManager: SystemStoryManagerProtocol,
|
|
tsAccountManager: TSAccountManager,
|
|
typingIndicators: TypingIndicators,
|
|
udManager: OWSUDManager,
|
|
usernameEducationManager: UsernameEducationManager
|
|
) {
|
|
self.localIdentifiers = localIdentifiers
|
|
self.isPrimaryDevice = isPrimaryDevice
|
|
|
|
self.authedAccount = authedAccount
|
|
self.backupSubscriptionManager = backupSubscriptionManager
|
|
self.dmConfigurationStore = dmConfigurationStore
|
|
self.linkPreviewSettingStore = linkPreviewSettingStore
|
|
self.localUsernameManager = localUsernameManager
|
|
self.paymentsHelper = paymentsHelper
|
|
self.phoneNumberDiscoverabilityManager = phoneNumberDiscoverabilityManager
|
|
self.pinnedThreadManager = pinnedThreadManager
|
|
self.preferences = preferences
|
|
self.profileManager = profileManager
|
|
self.receiptManager = receiptManager
|
|
self.registrationStateChangeManager = registrationStateChangeManager
|
|
self.storageServiceManager = storageServiceManager
|
|
self.systemStoryManager = systemStoryManager
|
|
self.tsAccountManager = tsAccountManager
|
|
self.typingIndicators = typingIndicators
|
|
self.udManager = udManager
|
|
self.usernameEducationManager = usernameEducationManager
|
|
}
|
|
|
|
func unknownFields(for record: StorageServiceProtoAccountRecord) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoAccountRecord) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .account), account: record)
|
|
}
|
|
|
|
func buildRecord(
|
|
for ignoredId: Void,
|
|
unknownFields: UnknownStorage?,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoAccountRecord? {
|
|
var builder = StorageServiceProtoAccountRecord.builder()
|
|
|
|
let localAddress = localIdentifiers.aciAddress
|
|
|
|
if let profileKey = profileManager.profileKeyData(for: localAddress, transaction: transaction) {
|
|
builder.setProfileKey(profileKey)
|
|
}
|
|
|
|
let localUsernameState = localUsernameManager.usernameState(tx: transaction.asV2Read)
|
|
if let username = localUsernameState.username {
|
|
builder.setUsername(username)
|
|
|
|
if let usernameLink = localUsernameState.usernameLink {
|
|
var usernameLinkProtoBuilder = StorageServiceProtoAccountRecordUsernameLink.builder()
|
|
|
|
usernameLinkProtoBuilder.setEntropy(usernameLink.entropy)
|
|
usernameLinkProtoBuilder.setServerID(usernameLink.handle.data)
|
|
usernameLinkProtoBuilder.setColor(
|
|
localUsernameManager.usernameLinkQRCodeColor(
|
|
tx: transaction.asV2Read
|
|
).asProto
|
|
)
|
|
|
|
builder.setUsernameLink(usernameLinkProtoBuilder.buildInfallibly())
|
|
}
|
|
}
|
|
|
|
if let profileGivenName = profileManager.unfilteredGivenName(for: localAddress, transaction: transaction) {
|
|
builder.setGivenName(profileGivenName)
|
|
}
|
|
if let profileFamilyName = profileManager.unfilteredFamilyName(for: localAddress, transaction: transaction) {
|
|
builder.setFamilyName(profileFamilyName)
|
|
}
|
|
|
|
if let profileAvatarUrlPath = profileManager.profileAvatarURLPath(
|
|
for: localAddress,
|
|
transaction: transaction
|
|
) {
|
|
Logger.info("profileAvatarUrlPath: yes")
|
|
builder.setAvatarURL(profileAvatarUrlPath)
|
|
} else {
|
|
Logger.info("profileAvatarUrlPath: no")
|
|
}
|
|
|
|
if let thread = TSContactThread.getWithContactAddress(localAddress, transaction: transaction) {
|
|
let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: thread, transaction: transaction)
|
|
|
|
builder.setNoteToSelfArchived(threadAssociatedData.isArchived)
|
|
builder.setNoteToSelfMarkedUnread(threadAssociatedData.isMarkedUnread)
|
|
}
|
|
|
|
let readReceiptsEnabled = OWSReceiptManager.areReadReceiptsEnabled(transaction: transaction)
|
|
builder.setReadReceipts(readReceiptsEnabled)
|
|
|
|
let storyViewReceiptsEnabled = StoryManager.areViewReceiptsEnabled(transaction: transaction)
|
|
builder.setStoryViewReceiptsEnabled(.init(storyViewReceiptsEnabled))
|
|
|
|
let sealedSenderIndicatorsEnabled = preferences.shouldShowUnidentifiedDeliveryIndicators(transaction: transaction)
|
|
builder.setSealedSenderIndicators(sealedSenderIndicatorsEnabled)
|
|
|
|
let typingIndicatorsEnabled = typingIndicators.areTypingIndicatorsEnabled()
|
|
builder.setTypingIndicators(typingIndicatorsEnabled)
|
|
|
|
let proxiedLinkPreviewsEnabled = SSKPreferences.areLegacyLinkPreviewsEnabled(transaction: transaction)
|
|
builder.setProxiedLinkPreviews(proxiedLinkPreviewsEnabled)
|
|
|
|
let linkPreviewsEnabled = linkPreviewSettingStore.areLinkPreviewsEnabled(tx: transaction.asV2Read)
|
|
builder.setLinkPreviews(linkPreviewsEnabled)
|
|
|
|
let phoneNumberSharingMode = udManager.phoneNumberSharingMode(tx: transaction.asV2Read)
|
|
builder.setPhoneNumberSharingMode(phoneNumberSharingMode.asProtoMode)
|
|
|
|
builder.setNotDiscoverableByPhoneNumber(
|
|
tsAccountManager.phoneNumberDiscoverability(tx: transaction.asV2Read).orDefault.isNotDiscoverableByPhoneNumber
|
|
)
|
|
|
|
let pinnedConversationProtos = self.pinnedConversationProtos(transaction: transaction)
|
|
builder.setPinnedConversations(pinnedConversationProtos)
|
|
|
|
let preferContactAvatars = SSKPreferences.preferContactAvatars(transaction: transaction)
|
|
builder.setPreferContactAvatars(preferContactAvatars)
|
|
|
|
let paymentsState = paymentsHelper.paymentsState
|
|
var paymentsBuilder = StorageServiceProtoAccountRecordPayments.builder()
|
|
paymentsBuilder.setEnabled(paymentsState.isEnabled)
|
|
if let paymentsEntropy = paymentsState.paymentsEntropy {
|
|
paymentsBuilder.setPaymentsEntropy(paymentsEntropy)
|
|
}
|
|
builder.setPayments(paymentsBuilder.buildInfallibly())
|
|
|
|
if let unknownFields = unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
|
|
let dmConfiguration = dmConfigurationStore.fetchOrBuildDefault(for: .universal, tx: transaction.asV2Read)
|
|
builder.setUniversalExpireTimer(dmConfiguration.isEnabled ? dmConfiguration.durationSeconds : 0)
|
|
|
|
if let customEmojiSet = ReactionManager.customEmojiSet(transaction: transaction) {
|
|
builder.setPreferredReactionEmoji(customEmojiSet)
|
|
}
|
|
|
|
if
|
|
let donationSubscriberID = DonationSubscriptionManager.getSubscriberID(transaction: transaction),
|
|
let donationSubscriberCurrencyCode = DonationSubscriptionManager.getSubscriberCurrencyCode(transaction: transaction)
|
|
{
|
|
builder.setDonorSubscriberID(donationSubscriberID)
|
|
builder.setDonorSubscriberCurrencyCode(donationSubscriberCurrencyCode)
|
|
}
|
|
builder.setDonorSubscriptionManuallyCancelled(DonationSubscriptionManager.userManuallyCancelledSubscription(transaction: transaction))
|
|
|
|
if let backupSubscriberData = backupSubscriptionManager.getIAPSubscriberData(tx: transaction.asV2Read) {
|
|
var subscriberDataBuilder = StorageServiceProtoAccountRecordIAPSubscriberData.builder()
|
|
subscriberDataBuilder.setSubscriberID(backupSubscriberData.subscriberId)
|
|
|
|
switch backupSubscriberData.iapSubscriptionId {
|
|
case .originalTransactionId(let value):
|
|
subscriberDataBuilder.setIapSubscriptionID(.originalTransactionID(value))
|
|
case .purchaseToken(let value):
|
|
subscriberDataBuilder.setIapSubscriptionID(.purchaseToken(value))
|
|
}
|
|
|
|
builder.setBackupSubscriberData(subscriberDataBuilder.buildInfallibly())
|
|
}
|
|
|
|
builder.setMyStoryPrivacyHasBeenSet(StoryManager.hasSetMyStoriesPrivacy(transaction: transaction))
|
|
|
|
builder.setReadOnboardingStory(systemStoryManager.isOnboardingStoryRead(transaction: transaction))
|
|
builder.setViewedOnboardingStory(systemStoryManager.isOnboardingStoryViewed(transaction: transaction))
|
|
|
|
builder.setDisplayBadgesOnProfile(DonationSubscriptionManager.displayBadgesOnProfile(transaction: transaction))
|
|
|
|
builder.setKeepMutedChatsArchived(SSKPreferences.shouldKeepMutedChatsArchived(transaction: transaction))
|
|
|
|
builder.setStoriesDisabled(!StoryManager.areStoriesEnabled(transaction: transaction))
|
|
|
|
builder.setCompletedUsernameOnboarding(
|
|
!usernameEducationManager.shouldShowUsernameEducation(tx: transaction.asV2Read)
|
|
)
|
|
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoAccountRecord,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<Void> {
|
|
var needsUpdate = false
|
|
|
|
let localAddress = localIdentifiers.aciAddress
|
|
|
|
// Gather some local contact state to do comparisons against.
|
|
let localUserProfile = profileManager.getUserProfile(for: localAddress, transaction: transaction)
|
|
let localAvatarUrl = profileManager.profileAvatarURLPath(
|
|
for: localAddress,
|
|
transaction: transaction
|
|
)
|
|
|
|
// On the primary device, we only ever want to take the profile key from
|
|
// storage service if we have no record of a local profile. This allows us
|
|
// to restore your profile during onboarding but ensures no other device
|
|
// can ever change the profile key other than the primary device.
|
|
let allowsRemoteProfileKeyChanges = !profileManager.hasLocalProfile || !isPrimaryDevice
|
|
if allowsRemoteProfileKeyChanges, let profileKey = record.profileKey, localUserProfile?.profileKey?.keyData != profileKey {
|
|
profileManager.setProfileKeyData(
|
|
profileKey,
|
|
for: localIdentifiers.aci,
|
|
onlyFillInIfMissing: false,
|
|
shouldFetchProfile: true,
|
|
userProfileWriter: .storageService,
|
|
localIdentifiers: localIdentifiers,
|
|
authedAccount: authedAccount,
|
|
tx: transaction.asV2Write
|
|
)
|
|
} else if localUserProfile?.profileKey != nil && !record.hasProfileKey {
|
|
// If we have a local profile key for this user but the service doesn't, mark it as needing update.
|
|
needsUpdate = true
|
|
}
|
|
|
|
// We normalize the names based on what we'd eventually send to the server
|
|
// when uploading our profile. If we don't, then we'd eventually change our
|
|
// profile name when reuploading anyways (this isn't that bad). However! If
|
|
// the normalized version becomes nil/empty, then reuploading would cause
|
|
// us to clear our profile name, and that's bad. Therefore, we must ensure
|
|
// values from Storage Service are valid before accepting them.
|
|
let remoteGivenName = record.givenName
|
|
let remoteFamilyName = record.familyName
|
|
|
|
let remoteGivenNameComponent = remoteGivenName.flatMap { OWSUserProfile.NameComponent(truncating: $0) }
|
|
let remoteFamilyNameComponent = remoteFamilyName.flatMap { OWSUserProfile.NameComponent(truncating: $0) }
|
|
|
|
let normalizedRemoteGivenName = remoteGivenNameComponent?.stringValue.rawValue
|
|
let normalizedRemoteFamilyName = remoteFamilyNameComponent?.stringValue.rawValue
|
|
|
|
// If we had to normalize the values, we need to put the normalized
|
|
// versions back into Storage Service for our other devices. Note: If all
|
|
// of our linked devices are properly enforcing the name length limits &
|
|
// stripping behaviors, this should be impossible.
|
|
if remoteGivenName != normalizedRemoteGivenName || remoteFamilyName != normalizedRemoteFamilyName {
|
|
needsUpdate = true
|
|
}
|
|
|
|
// Given name can never be cleared, so ignore all info about the profile if
|
|
// there's no given name.
|
|
if let normalizedRemoteGivenName, (
|
|
localUserProfile?.givenName != normalizedRemoteGivenName
|
|
|| localUserProfile?.familyName != normalizedRemoteFamilyName
|
|
|| localAvatarUrl != record.avatarURL
|
|
) {
|
|
let localUserProfile = OWSUserProfile.getOrBuildUserProfileForLocalUser(
|
|
userProfileWriter: .storageService,
|
|
tx: transaction
|
|
)
|
|
localUserProfile.update(
|
|
givenName: .setTo(normalizedRemoteGivenName),
|
|
familyName: .setTo(normalizedRemoteFamilyName),
|
|
avatarUrlPath: .setTo(record.avatarURL),
|
|
userProfileWriter: .storageService,
|
|
transaction: transaction,
|
|
completion: nil
|
|
)
|
|
transaction.addSyncCompletion { [authedAccount, profileManager] in
|
|
Task {
|
|
do {
|
|
try await profileManager.downloadAndDecryptLocalUserAvatarIfNeeded(authedAccount: authedAccount)
|
|
} catch {
|
|
Logger.warn("Couldn't download local avatar: \(error)")
|
|
}
|
|
}
|
|
}
|
|
} else if (
|
|
localUserProfile?.givenName != nil && !record.hasGivenName
|
|
|| localUserProfile?.familyName != nil && !record.hasFamilyName
|
|
|| localAvatarUrl != nil && !record.hasAvatarURL
|
|
) {
|
|
needsUpdate = true
|
|
}
|
|
|
|
if let remoteUsername = record.username {
|
|
if
|
|
let remoteUsernameLinkProto = record.usernameLink,
|
|
let remoteUsernameLinkProtoHandleData = remoteUsernameLinkProto.serverID,
|
|
let remoteUsernameLinkProtoHandle = UUID(data: remoteUsernameLinkProtoHandleData),
|
|
let remoteUsernameLinkProtoEntropy = remoteUsernameLinkProto.entropy,
|
|
let remoteUsernameLink = Usernames.UsernameLink(
|
|
handle: remoteUsernameLinkProtoHandle,
|
|
entropy: remoteUsernameLinkProtoEntropy
|
|
)
|
|
{
|
|
localUsernameManager.setLocalUsername(
|
|
username: remoteUsername,
|
|
usernameLink: remoteUsernameLink,
|
|
tx: transaction.asV2Write
|
|
)
|
|
|
|
localUsernameManager.setUsernameLinkQRCodeColor(
|
|
color: QRCodeColor(proto: remoteUsernameLinkProto.color),
|
|
tx: transaction.asV2Write
|
|
)
|
|
} else {
|
|
localUsernameManager.setLocalUsernameWithCorruptedLink(
|
|
username: remoteUsername,
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
} else {
|
|
localUsernameManager.clearLocalUsername(tx: transaction.asV2Write)
|
|
}
|
|
|
|
let localThread = TSContactThread.getOrCreateThread(withContactAddress: localAddress, transaction: transaction)
|
|
let localThreadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: localThread, transaction: transaction)
|
|
|
|
if record.noteToSelfArchived != localThreadAssociatedData.isArchived {
|
|
localThreadAssociatedData.updateWith(isArchived: record.noteToSelfArchived, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
if record.noteToSelfMarkedUnread != localThreadAssociatedData.isMarkedUnread {
|
|
localThreadAssociatedData.updateWith(isMarkedUnread: record.noteToSelfMarkedUnread, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
let localReadReceiptsEnabled = receiptManager.areReadReceiptsEnabled()
|
|
if record.readReceipts != localReadReceiptsEnabled {
|
|
receiptManager.setAreReadReceiptsEnabled(record.readReceipts, transaction: transaction)
|
|
}
|
|
|
|
let localViewReceiptsEnabled = StoryManager.areViewReceiptsEnabled(transaction: transaction)
|
|
if let storyViewReceiptsEnabled = record.storyViewReceiptsEnabled.boolValue {
|
|
if storyViewReceiptsEnabled != localViewReceiptsEnabled {
|
|
StoryManager.setAreViewReceiptsEnabled(storyViewReceiptsEnabled, shouldUpdateStorageService: false, transaction: transaction)
|
|
}
|
|
} else {
|
|
needsUpdate = true
|
|
}
|
|
|
|
let sealedSenderIndicatorsEnabled = preferences.shouldShowUnidentifiedDeliveryIndicators(transaction: transaction)
|
|
if record.sealedSenderIndicators != sealedSenderIndicatorsEnabled {
|
|
preferences.setShouldShowUnidentifiedDeliveryIndicators(record.sealedSenderIndicators, transaction: transaction)
|
|
}
|
|
|
|
let typingIndicatorsEnabled = typingIndicators.areTypingIndicatorsEnabled()
|
|
if record.typingIndicators != typingIndicatorsEnabled {
|
|
typingIndicators.setTypingIndicatorsEnabled(value: record.typingIndicators, transaction: transaction)
|
|
}
|
|
|
|
let linkPreviewsEnabled = linkPreviewSettingStore.areLinkPreviewsEnabled(tx: transaction.asV2Read)
|
|
if record.linkPreviews != linkPreviewsEnabled {
|
|
linkPreviewSettingStore.setAreLinkPreviewsEnabled(record.linkPreviews, tx: transaction.asV2Write)
|
|
}
|
|
|
|
let proxiedLinkPreviewsEnabled = SSKPreferences.areLegacyLinkPreviewsEnabled(transaction: transaction)
|
|
if record.proxiedLinkPreviews != proxiedLinkPreviewsEnabled {
|
|
SSKPreferences.setAreLegacyLinkPreviewsEnabled(record.proxiedLinkPreviews, transaction: transaction)
|
|
}
|
|
|
|
let localPhoneNumberSharingMode = udManager.phoneNumberSharingMode(tx: transaction.asV2Read)
|
|
if record.phoneNumberSharingMode != localPhoneNumberSharingMode.asProtoMode {
|
|
if let localMode = record.phoneNumberSharingMode.asLocalMode {
|
|
udManager.setPhoneNumberSharingMode(localMode, updateStorageServiceAndProfile: false, tx: transaction)
|
|
} else {
|
|
Logger.error("Unknown phone number sharing mode \(String(describing: record.phoneNumberSharingMode))")
|
|
}
|
|
}
|
|
|
|
let localPhoneNumberDiscoverability = tsAccountManager.phoneNumberDiscoverability(tx: transaction.asV2Read)
|
|
if record.notDiscoverableByPhoneNumber != localPhoneNumberDiscoverability?.isNotDiscoverableByPhoneNumber {
|
|
phoneNumberDiscoverabilityManager.setPhoneNumberDiscoverability(
|
|
record.notDiscoverableByPhoneNumber ? .nobody : .everybody,
|
|
updateAccountAttributes: false,
|
|
updateStorageService: false,
|
|
authedAccount: authedAccount,
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
do {
|
|
try self.processPinnedConversationsProto(record.pinnedConversations, transaction: transaction)
|
|
} catch {
|
|
owsFailDebug("Failed to process pinned conversations \(error)")
|
|
needsUpdate = true
|
|
}
|
|
|
|
let localPrefersContactAvatars = SSKPreferences.preferContactAvatars(transaction: transaction)
|
|
if record.preferContactAvatars != localPrefersContactAvatars {
|
|
SSKPreferences.setPreferContactAvatars(
|
|
record.preferContactAvatars,
|
|
updateStorageService: false,
|
|
transaction: transaction)
|
|
}
|
|
|
|
let localPaymentsState = paymentsHelper.paymentsState
|
|
let servicePaymentsState = PaymentsState.build(
|
|
arePaymentsEnabled: record.payments?.enabled ?? false,
|
|
paymentsEntropy: record.payments?.paymentsEntropy
|
|
)
|
|
if localPaymentsState != servicePaymentsState {
|
|
let mergedPaymentsState = PaymentsState.build(
|
|
// Honor "arePaymentsEnabled" from the service.
|
|
arePaymentsEnabled: servicePaymentsState.isEnabled,
|
|
// Prefer paymentsEntropy from service, but try to retain local paymentsEntropy otherwise.
|
|
paymentsEntropy: servicePaymentsState.paymentsEntropy ?? localPaymentsState.paymentsEntropy
|
|
)
|
|
paymentsHelper.setPaymentsState(
|
|
mergedPaymentsState,
|
|
originatedLocally: false,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
let remoteExpireToken: DisappearingMessageToken = .token(forProtoExpireTimerSeconds: record.universalExpireTimer)
|
|
dmConfigurationStore.setUniversalTimer(token: remoteExpireToken, tx: transaction.asV2Write)
|
|
|
|
if !record.preferredReactionEmoji.isEmpty {
|
|
// Treat new preferred emoji as a full source of truth (if not empty). Note
|
|
// that we aren't doing any validation up front, which may be important if
|
|
// another platform supports an emoji we don't (say, because a new version
|
|
// of Unicode has come out). We deal with this when the custom set is read.
|
|
ReactionManager.setCustomEmojiSet(record.preferredReactionEmoji, transaction: transaction)
|
|
}
|
|
|
|
if
|
|
let donationSubscriberId = record.donorSubscriberID,
|
|
let donationSubscriberCurrencyCode = record.donorSubscriberCurrencyCode
|
|
{
|
|
if donationSubscriberId != DonationSubscriptionManager.getSubscriberID(transaction: transaction) {
|
|
DonationSubscriptionManager.setSubscriberID(donationSubscriberId, transaction: transaction)
|
|
}
|
|
|
|
if donationSubscriberCurrencyCode != DonationSubscriptionManager.getSubscriberCurrencyCode(transaction: transaction) {
|
|
DonationSubscriptionManager.setSubscriberCurrencyCode(donationSubscriberCurrencyCode, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
let localDonationSubscriptionManuallyCancelled = DonationSubscriptionManager.userManuallyCancelledSubscription(transaction: transaction)
|
|
if localDonationSubscriptionManuallyCancelled != record.donorSubscriptionManuallyCancelled {
|
|
DonationSubscriptionManager.setUserManuallyCancelledSubscription(
|
|
record.donorSubscriptionManuallyCancelled,
|
|
updateStorageService: false,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
if
|
|
let backupSubscriberData = record.backupSubscriberData,
|
|
let subscriberId = backupSubscriberData.subscriberID,
|
|
let iapSubscriptionIdProto = backupSubscriberData.iapSubscriptionID
|
|
{
|
|
typealias IAPSubscriberData = BackupSubscription.IAPSubscriberData
|
|
|
|
let iapSubscriptionId: IAPSubscriberData.IAPSubscriptionId
|
|
switch iapSubscriptionIdProto {
|
|
case .originalTransactionID(let value):
|
|
iapSubscriptionId = .originalTransactionId(value)
|
|
case .purchaseToken(let value):
|
|
iapSubscriptionId = .purchaseToken(value)
|
|
}
|
|
|
|
backupSubscriptionManager.restoreIAPSubscriberData(
|
|
IAPSubscriberData(
|
|
subscriberId: subscriberId,
|
|
iapSubscriptionId: iapSubscriptionId
|
|
),
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
let localDisplayBadgesOnProfile = DonationSubscriptionManager.displayBadgesOnProfile(transaction: transaction)
|
|
if localDisplayBadgesOnProfile != record.displayBadgesOnProfile {
|
|
DonationSubscriptionManager.setDisplayBadgesOnProfile(
|
|
record.displayBadgesOnProfile,
|
|
updateStorageService: false,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
let localKeepMutedChatsArchived = SSKPreferences.shouldKeepMutedChatsArchived(transaction: transaction)
|
|
if localKeepMutedChatsArchived != record.keepMutedChatsArchived {
|
|
SSKPreferences.setShouldKeepMutedChatsArchived(record.keepMutedChatsArchived, transaction: transaction)
|
|
}
|
|
|
|
let localHasSetMyStoriesPrivacy = StoryManager.hasSetMyStoriesPrivacy(transaction: transaction)
|
|
if !localHasSetMyStoriesPrivacy && record.myStoryPrivacyHasBeenSet {
|
|
StoryManager.setHasSetMyStoriesPrivacy(true, shouldUpdateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
let localHasReadOnboardingStory = systemStoryManager.isOnboardingStoryRead(transaction: transaction)
|
|
if !localHasReadOnboardingStory && record.readOnboardingStory {
|
|
systemStoryManager.setHasReadOnboardingStory(transaction: transaction, updateStorageService: false)
|
|
}
|
|
|
|
let localHasViewedOnboardingStory = systemStoryManager.isOnboardingStoryViewed(transaction: transaction)
|
|
if !localHasViewedOnboardingStory && record.viewedOnboardingStory {
|
|
try? systemStoryManager.setHasViewedOnboardingStory(source: .otherDevice, transaction: transaction)
|
|
}
|
|
|
|
let localStoriesDisabled = !StoryManager.areStoriesEnabled(transaction: transaction)
|
|
if localStoriesDisabled != record.storiesDisabled {
|
|
StoryManager.setAreStoriesEnabled(!record.storiesDisabled, shouldUpdateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
let hasCompletedUsernameOnboarding = !usernameEducationManager.shouldShowUsernameEducation(tx: transaction.asV2Read)
|
|
if !hasCompletedUsernameOnboarding && record.completedUsernameOnboarding {
|
|
usernameEducationManager.setShouldShowUsernameEducation(
|
|
false,
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
return .merged(needsUpdate: needsUpdate, ())
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension Optional where Wrapped == PhoneNumberSharingMode {
|
|
var asProtoMode: StorageServiceProtoAccountRecordPhoneNumberSharingMode {
|
|
switch self {
|
|
case .none: return .unknown
|
|
case .nobody: return .nobody
|
|
case .everybody: return .everybody
|
|
}
|
|
}
|
|
}
|
|
|
|
extension StorageServiceProtoAccountRecordPhoneNumberSharingMode {
|
|
var asLocalMode: PhoneNumberSharingMode? {
|
|
switch self {
|
|
case .unknown: return nil
|
|
case .everybody: return .everybody
|
|
case .nobody: return .nobody
|
|
default:
|
|
owsFailDebug("unexpected case \(self)")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension StorageServiceAccountRecordUpdater {
|
|
|
|
fileprivate func processPinnedConversationsProto(
|
|
_ pinnedConversations: [StorageServiceProtoAccountRecordPinnedConversation],
|
|
transaction: SDSAnyWriteTransaction
|
|
) throws {
|
|
if pinnedConversations.count > PinnedThreads.maxPinnedThreads {
|
|
Logger.warn("Received unexpected number of pinned threads (\(pinnedConversations.count))")
|
|
}
|
|
|
|
var pinnedThreadIds = [String]()
|
|
for pinnedConversation in pinnedConversations {
|
|
switch pinnedConversation.identifier {
|
|
case .contact(let contact)?:
|
|
let address = SignalServiceAddress.legacyAddress(
|
|
serviceIdString: contact.serviceID,
|
|
phoneNumber: contact.e164
|
|
)
|
|
guard address.isValid else {
|
|
owsFailDebug("Dropping pinned thread with invalid address \(address)")
|
|
continue
|
|
}
|
|
let thread = TSContactThread.getOrCreateThread(withContactAddress: address, transaction: transaction)
|
|
pinnedThreadIds.append(thread.uniqueId)
|
|
case .groupMasterKey(let masterKey)?:
|
|
let contextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: masterKey)
|
|
let threadUniqueId = TSGroupThread.threadId(forGroupId: contextInfo.groupId,
|
|
transaction: transaction)
|
|
pinnedThreadIds.append(threadUniqueId)
|
|
case .legacyGroupID(let groupId)?:
|
|
let threadUniqueId = TSGroupThread.threadId(forGroupId: groupId,
|
|
transaction: transaction)
|
|
pinnedThreadIds.append(threadUniqueId)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
pinnedThreadManager.updatePinnedThreadIds(pinnedThreadIds, updateStorageService: false, tx: transaction.asV2Write)
|
|
}
|
|
|
|
fileprivate func pinnedConversationProtos(
|
|
transaction: SDSAnyReadTransaction
|
|
) -> [StorageServiceProtoAccountRecordPinnedConversation] {
|
|
let pinnedThreads = pinnedThreadManager.pinnedThreads(tx: transaction.asV2Read)
|
|
|
|
var pinnedConversationProtos = [StorageServiceProtoAccountRecordPinnedConversation]()
|
|
for pinnedThread in pinnedThreads {
|
|
var pinnedConversationBuilder = StorageServiceProtoAccountRecordPinnedConversation.builder()
|
|
|
|
if let groupThread = pinnedThread as? TSGroupThread {
|
|
if let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 {
|
|
let masterKey: GroupMasterKey
|
|
do {
|
|
masterKey = try groupModelV2.masterKey()
|
|
} catch {
|
|
owsFailDebug("Missing master key: \(error)")
|
|
continue
|
|
}
|
|
pinnedConversationBuilder.setIdentifier(.groupMasterKey(masterKey.serialize().asData))
|
|
} else {
|
|
pinnedConversationBuilder.setIdentifier(.legacyGroupID(groupThread.groupModel.groupId))
|
|
}
|
|
|
|
} else if let contactThread = pinnedThread as? TSContactThread {
|
|
var contactBuilder = StorageServiceProtoAccountRecordPinnedConversationContact.builder()
|
|
if let serviceIdString = contactThread.contactAddress.serviceIdString {
|
|
contactBuilder.setServiceID(serviceIdString)
|
|
} else if let e164 = contactThread.contactAddress.phoneNumber {
|
|
contactBuilder.setE164(e164)
|
|
} else {
|
|
owsFailDebug("Missing uuid and phone number for thread")
|
|
}
|
|
pinnedConversationBuilder.setIdentifier(.contact(contactBuilder.buildInfallibly()))
|
|
}
|
|
|
|
pinnedConversationProtos.append(pinnedConversationBuilder.buildInfallibly())
|
|
}
|
|
|
|
return pinnedConversationProtos
|
|
}
|
|
}
|
|
|
|
// MARK: - Story Distribution List Record
|
|
|
|
class StorageServiceStoryDistributionListRecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = Data
|
|
typealias RecordType = StorageServiceProtoStoryDistributionListRecord
|
|
|
|
private let privateStoryThreadDeletionManager: any PrivateStoryThreadDeletionManager
|
|
private let threadRemover: any ThreadRemover
|
|
|
|
init(
|
|
privateStoryThreadDeletionManager: any PrivateStoryThreadDeletionManager,
|
|
threadRemover: any ThreadRemover
|
|
) {
|
|
self.privateStoryThreadDeletionManager = privateStoryThreadDeletionManager
|
|
self.threadRemover = threadRemover
|
|
}
|
|
|
|
func unknownFields(for record: StorageServiceProtoStoryDistributionListRecord) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoStoryDistributionListRecord) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .storyDistributionList), storyDistributionList: record)
|
|
}
|
|
|
|
func buildRecord(
|
|
for distributionListIdentifier: Data,
|
|
unknownFields: UnknownStorage?,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoStoryDistributionListRecord? {
|
|
guard let uniqueId = UUID(data: distributionListIdentifier)?.uuidString else {
|
|
owsFailDebug("Invalid distributionListIdentifier.")
|
|
return nil
|
|
}
|
|
|
|
var builder = StorageServiceProtoStoryDistributionListRecord.builder()
|
|
builder.setIdentifier(distributionListIdentifier)
|
|
|
|
if let deletedAtTimestamp = privateStoryThreadDeletionManager.deletedAtTimestamp(
|
|
forDistributionListIdentifier: distributionListIdentifier,
|
|
tx: transaction.asV2Read
|
|
) {
|
|
builder.setDeletedAtTimestamp(deletedAtTimestamp)
|
|
} else if let story = TSPrivateStoryThread.anyFetchPrivateStoryThread(
|
|
uniqueId: uniqueId,
|
|
transaction: transaction
|
|
) {
|
|
builder.setName(story.name)
|
|
builder.setRecipientServiceIds(story.addresses.compactMap { $0.serviceId?.serviceIdString })
|
|
builder.setAllowsReplies(story.allowsReplies)
|
|
builder.setIsBlockList(story.storyViewMode == .blockList)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
// Unknown
|
|
|
|
if let unknownFields = unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoStoryDistributionListRecord,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<Data> {
|
|
guard let identifier = record.identifier, let uniqueId = UUID(data: identifier)?.uuidString else {
|
|
owsFailDebug("identifier unexpectedly missing for distribution list")
|
|
return .invalid
|
|
}
|
|
|
|
let existingStory = TSPrivateStoryThread.anyFetchPrivateStoryThread(
|
|
uniqueId: uniqueId,
|
|
transaction: transaction
|
|
)
|
|
|
|
// The story has been deleted on another device, record that
|
|
// and ensure we don't try and put it back.
|
|
guard record.deletedAtTimestamp == 0 else {
|
|
if let existingStory {
|
|
threadRemover.remove(existingStory, tx: transaction.asV2Write)
|
|
}
|
|
privateStoryThreadDeletionManager.recordDeletedAtTimestamp(
|
|
record.deletedAtTimestamp,
|
|
forDistributionListIdentifier: identifier,
|
|
tx: transaction.asV2Write
|
|
)
|
|
return .merged(needsUpdate: false, identifier)
|
|
}
|
|
|
|
var needsUpdate = false
|
|
|
|
let remoteRecipientServiceIds = record.recipientServiceIds.compactMap { (serviceIdString) -> ServiceId? in
|
|
guard let serviceId = try? ServiceId.parseFrom(serviceIdString: serviceIdString) else {
|
|
return nil
|
|
}
|
|
return serviceId
|
|
}
|
|
|
|
if let story = existingStory {
|
|
// My Story has a hardcoded, localized name that we don't sync
|
|
if !story.isMyStory {
|
|
let localName = story.name
|
|
if let name = record.name, localName != name {
|
|
story.updateWithName(name, updateStorageService: false, transaction: transaction)
|
|
} else if !record.hasName {
|
|
needsUpdate = true
|
|
}
|
|
}
|
|
|
|
let localAllowsReplies = story.allowsReplies
|
|
if record.allowsReplies != localAllowsReplies {
|
|
story.updateWithAllowsReplies(record.allowsReplies, updateStorageService: false, transaction: transaction)
|
|
}
|
|
|
|
let localStoryIsBlocklist = story.storyViewMode == .blockList
|
|
let localRecipientServiceIds = story.addresses.compactMap { $0.serviceId }
|
|
if localStoryIsBlocklist != record.isBlockList || Set(localRecipientServiceIds) != Set(remoteRecipientServiceIds) {
|
|
story.updateWithStoryViewMode(
|
|
record.isBlockList ? .blockList : .explicit,
|
|
addresses: remoteRecipientServiceIds.map { SignalServiceAddress($0) },
|
|
updateStorageService: false,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
} else {
|
|
guard let name = record.name else {
|
|
owsFailDebug("new private story missing required name")
|
|
return .invalid
|
|
}
|
|
let newStory = TSPrivateStoryThread(
|
|
uniqueId: uniqueId,
|
|
name: name,
|
|
allowsReplies: record.allowsReplies,
|
|
addresses: remoteRecipientServiceIds.map { SignalServiceAddress($0) },
|
|
viewMode: record.isBlockList ? .blockList : .explicit
|
|
)
|
|
newStory.anyInsert(transaction: transaction)
|
|
}
|
|
|
|
return .merged(needsUpdate: needsUpdate, identifier)
|
|
}
|
|
}
|
|
|
|
// MARK: - Call Link Record
|
|
|
|
class StorageServiceCallLinkRecordUpdater: StorageServiceRecordUpdater {
|
|
typealias IdType = Data
|
|
typealias RecordType = StorageServiceProtoCallLinkRecord
|
|
|
|
let callLinkStore: any CallLinkRecordStore
|
|
private let callRecordDeleteManager: any CallRecordDeleteManager
|
|
private let callRecordStore: any CallRecordStore
|
|
|
|
init(
|
|
callLinkStore: any CallLinkRecordStore,
|
|
callRecordDeleteManager: any CallRecordDeleteManager,
|
|
callRecordStore: any CallRecordStore
|
|
) {
|
|
self.callLinkStore = callLinkStore
|
|
self.callRecordDeleteManager = callRecordDeleteManager
|
|
self.callRecordStore = callRecordStore
|
|
}
|
|
|
|
func unknownFields(for record: StorageServiceProtoCallLinkRecord) -> UnknownStorage? { record.unknownFields }
|
|
|
|
func buildStorageItem(for record: StorageServiceProtoCallLinkRecord) -> StorageService.StorageItem {
|
|
return StorageService.StorageItem(identifier: .generate(type: .callLink), callLink: record)
|
|
}
|
|
|
|
func buildRecord(
|
|
for rootKeyData: Data,
|
|
unknownFields: UnknownStorage?,
|
|
transaction tx: SDSAnyReadTransaction
|
|
) -> StorageServiceProtoCallLinkRecord? {
|
|
guard let rootKey = try? CallLinkRootKey(rootKeyData) else {
|
|
owsFailDebug("Invalid CallLinkRootKey")
|
|
return nil
|
|
}
|
|
let roomId = rootKey.deriveRoomId()
|
|
let callLink: CallLinkRecord?
|
|
do {
|
|
callLink = try self.callLinkStore.fetch(roomId: roomId, tx: tx.asV2Read)
|
|
} catch {
|
|
owsFailDebug("Skipping CallLink that can't be fetched: \(rootKey.description)")
|
|
return nil
|
|
}
|
|
|
|
guard let callLink, callLink.adminPasskey != nil || callLink.adminDeletedAtTimestampMs != nil else {
|
|
// We're not an admin, so this link doesn't go in Storage Service.
|
|
return nil
|
|
}
|
|
|
|
var builder = StorageServiceProtoCallLinkRecord.builder()
|
|
builder.setRootKey(rootKey.bytes)
|
|
if let adminDeletedAtTimestampMs = callLink.adminDeletedAtTimestampMs {
|
|
builder.setDeletedAtTimestampMs(adminDeletedAtTimestampMs)
|
|
} else if let adminPasskey = callLink.adminPasskey {
|
|
builder.setAdminPasskey(adminPasskey)
|
|
}
|
|
|
|
if let unknownFields {
|
|
builder.setUnknownFields(unknownFields)
|
|
}
|
|
return builder.buildInfallibly()
|
|
}
|
|
|
|
func mergeRecord(
|
|
_ record: StorageServiceProtoCallLinkRecord,
|
|
transaction tx: SDSAnyWriteTransaction
|
|
) -> StorageServiceMergeResult<Data> {
|
|
guard let rootKeyData = record.rootKey, let rootKey = try? CallLinkRootKey(rootKeyData) else {
|
|
owsFailDebug("invalid rootKey")
|
|
return .invalid
|
|
}
|
|
do {
|
|
var (callLink, _) = try self.callLinkStore.fetchOrInsert(rootKey: rootKey, tx: tx.asV2Write)
|
|
// The earliest deletion timestamp takes precendence when merging.
|
|
if record.deletedAtTimestampMs > 0 || callLink.adminDeletedAtTimestampMs != nil {
|
|
self.callRecordDeleteManager.deleteCallRecords(
|
|
try self.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLink.id), limit: nil, tx: tx.asV2Read),
|
|
sendSyncMessageOnDelete: false,
|
|
tx: tx.asV2Write
|
|
)
|
|
callLink.markDeleted(atTimestampMs: [record.deletedAtTimestampMs, callLink.adminDeletedAtTimestampMs].compacted().min()!)
|
|
} else if let adminPasskey = record.adminPasskey?.nilIfEmpty {
|
|
callLink.adminPasskey = adminPasskey
|
|
callLink.setNeedsFetch()
|
|
}
|
|
try self.callLinkStore.update(callLink, tx: tx.asV2Write)
|
|
} catch {
|
|
owsFailDebug("Couldn't merge CallLink \(rootKey.description): \(error)")
|
|
}
|
|
return .merged(needsUpdate: false, rootKey.bytes)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension StorageServiceProtoOptionalBool {
|
|
var boolValue: Bool? {
|
|
switch self {
|
|
case .unset: return nil
|
|
case .true: return true
|
|
case .false: return false
|
|
case .UNRECOGNIZED: return nil
|
|
}
|
|
}
|
|
|
|
init(_ boolValue: Bool) {
|
|
self = boolValue ? .true : .false
|
|
}
|
|
}
|
|
|
|
private extension QRCodeColor {
|
|
var asProto: StorageServiceProtoAccountRecordUsernameLinkColor {
|
|
switch self {
|
|
case .blue: return .blue
|
|
case .white: return .white
|
|
case .grey: return .grey
|
|
case .olive: return .olive
|
|
case .green: return .green
|
|
case .orange: return .orange
|
|
case .pink: return .pink
|
|
case .purple: return .purple
|
|
}
|
|
}
|
|
|
|
init(proto: StorageServiceProtoAccountRecordUsernameLinkColor) {
|
|
switch proto {
|
|
case .blue: self = .blue
|
|
case .white: self = .white
|
|
case .grey: self = .grey
|
|
case .olive: self = .olive
|
|
case .green: self = .green
|
|
case .orange: self = .orange
|
|
case .pink: self = .pink
|
|
case .purple: self = .purple
|
|
case .unknown, .UNRECOGNIZED:
|
|
Logger.warn("Unrecognized username link color in proto!")
|
|
self = .unknown
|
|
}
|
|
}
|
|
}
|