Path: blob/main/SignalServiceKit/Profiles/OWSProfileManager.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
/// This class has been ported over from objc and the notes attached to that are reproduced below.
/// Note that the veracity of the thread safety claims seems dubious.
///
/// This class can be safely accessed and used from any thread.
///
/// Access to most state should happen while locked. Writes should happen off the main thread, wherever possible.
public class OWSProfileManager: ProfileManagerProtocol {
public static let maxAvatarDiameterPixels: UInt = 1024
public static let notificationKeyUserProfileWriter = "kNSNotificationKey_UserProfileWriter"
private let metadataStore = KeyValueStore(collection: "kOWSProfileManager_Metadata")
private let whitelistedGroupsStore = KeyValueStore(collection: "kOWSProfileManager_GroupWhitelistCollection")
private let settingsStore = KeyValueStore(collection: "kOWSProfileManager_SettingsStore")
private let pendingUpdateRequests = AtomicValue<[OWSProfileManager.ProfileUpdateRequest]>([], lock: .init())
/// Ensures that only one profile update is in flight at a time.
@MainActor
private var isUpdatingProfileOnService: Bool = false
@MainActor
private var isRotatingProfileKey: Bool = false
private let appReadiness: AppReadiness
public let badgeStore = BadgeStore()
@MainActor
init(appReadiness: AppReadiness, databaseStorage: SDSDatabaseStorage) {
self.appReadiness = appReadiness
SwiftSingletons.register(self)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
self.rotateLocalProfileKeyIfNecessary()
self.updateProfileOnServiceIfNecessary(authedAccount: .implicit())
Self.updateStorageServiceIfNecessary()
}
NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reachabilityChanged(_:)), name: SSKReachability.owsReachabilityDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(blockListDidChange(_:)), name: BlockingManager.blockListDidChange, object: nil)
}
public static func avatarData(avatarImage image: UIImage) -> Data? {
var image = image
let maxAvatarBytes = 5_000_000
if image.pixelWidth != maxAvatarDiameterPixels || image.pixelHeight != maxAvatarDiameterPixels {
// To help ensure the user is being shown the same cropping of their avatar as
// everyone else will see, we want to be sure that the image was resized before this point.
owsFailDebug("Avatar image should have been resized before trying to upload")
image = image.resizedImage(toFillPixelSize: .init(width: CGFloat(maxAvatarDiameterPixels), height: CGFloat(maxAvatarDiameterPixels)))
}
guard let data = image.jpegData(compressionQuality: 0.95) else {
return nil
}
if data.count > maxAvatarBytes {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't be able to fit our profile
// photo. e.g. generating pure noise at our resolution compresses to ~200k.
owsFailDebug("Surprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
}
return data
}
// MARK: - Profile Whitelist
public func setLocalProfileKey(_ key: Aes256Key, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
let localUserProfile = OWSUserProfile.getOrBuildUserProfileForLocalUser(userProfileWriter: .localUser, tx: transaction)
localUserProfile.update(profileKey: .setTo(key), userProfileWriter: userProfileWriter, transaction: transaction)
}
public func addRecipientToProfileWhitelist(
_ recipient: inout SignalRecipient,
userProfileWriter: UserProfileWriter,
tx: DBWriteTransaction,
) {
let blockingManager = SSKEnvironment.shared.blockingManagerRef
let hidingManager = DependenciesBridge.shared.recipientHidingManager
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
if blockingManager.isRecipientBlocked(recipientId: recipient.id, tx: tx) {
return
}
if hidingManager.isHiddenRecipient(recipientId: recipient.id, tx: tx) {
return
}
switch recipient.status {
case .whitelisted:
return
case .unspecified:
break
}
recipient.status = .whitelisted
recipientStore.updateRecipient(recipient, transaction: tx)
_didUpdateRecipientInWhitelist(recipient, userProfileWriter: userProfileWriter, tx: tx)
}
public func removeRecipientFromProfileWhitelist(
_ recipient: inout SignalRecipient,
userProfileWriter: UserProfileWriter,
tx: DBWriteTransaction,
) {
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
switch recipient.status {
case .unspecified:
return
case .whitelisted:
break
}
recipient.status = .unspecified
recipientStore.updateRecipient(recipient, transaction: tx)
_didUpdateRecipientInWhitelist(recipient, userProfileWriter: userProfileWriter, tx: tx)
}
private func _didUpdateRecipientInWhitelist(
_ recipient: SignalRecipient,
userProfileWriter: UserProfileWriter,
tx: DBWriteTransaction,
) {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef
if let thread = TSContactThread.getWithContactAddress(recipient.address, transaction: tx) {
databaseStorage.touch(thread: thread, shouldReindex: false, tx: tx)
}
tx.addSyncCompletion {
// Mark the new whitelisted addresses for update
if OWSUserProfile.shouldUpdateStorageServiceForUserProfileWriter(userProfileWriter) {
storageServiceManager.recordPendingUpdates(updatedAddresses: [recipient.address])
}
NotificationCenter.default.postOnMainThread(name: UserProfileNotifications.profileWhitelistDidChange, object: nil, userInfo: [
UserProfileNotifications.profileAddressKey: recipient.address,
Self.notificationKeyUserProfileWriter: NSNumber(value: userProfileWriter.rawValue),
])
}
}
public func isRecipientInProfileWhitelist(_ recipient: SignalRecipient, tx: DBReadTransaction) -> Bool {
let blockingManager = SSKEnvironment.shared.blockingManagerRef
let hidingManager = DependenciesBridge.shared.recipientHidingManager
return
!blockingManager.isRecipientBlocked(recipientId: recipient.id, tx: tx)
&& !hidingManager.isHiddenRecipient(recipientId: recipient.id, tx: tx)
&& recipient.status == .whitelisted
}
public func addGroupId(toProfileWhitelist groupId: Data, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
owsAssertDebug(!groupId.isEmpty)
let groupIdKey = groupKey(groupId: groupId)
if !whitelistedGroupsStore.hasValue(groupIdKey, transaction: transaction) {
addConfirmedUnwhitelistedGroupId(groupId, userProfileWriter: userProfileWriter, transaction: transaction)
}
}
public func removeGroupId(fromProfileWhitelist groupId: Data, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
owsAssertDebug(!groupId.isEmpty)
let groupIdKey = groupKey(groupId: groupId)
if whitelistedGroupsStore.hasValue(groupIdKey, transaction: transaction) {
removeConfirmedWhitelistedGroupId(groupId, userProfileWriter: userProfileWriter, transaction: transaction)
}
}
private func removeConfirmedWhitelistedGroupId(_ groupId: Data, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
owsAssertDebug(!groupId.isEmpty)
let groupIdKey = groupKey(groupId: groupId)
whitelistedGroupsStore.removeValue(forKey: groupIdKey, transaction: transaction)
groupIdWhitelistWasUpdated(groupId, userProfileWriter: userProfileWriter, transaction: transaction)
}
private func addConfirmedUnwhitelistedGroupId(_ groupId: Data, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
owsAssertDebug(!groupId.isEmpty)
let groupIdKey = groupKey(groupId: groupId)
whitelistedGroupsStore.setBool(true, key: groupIdKey, transaction: transaction)
groupIdWhitelistWasUpdated(groupId, userProfileWriter: userProfileWriter, transaction: transaction)
}
private func groupIdWhitelistWasUpdated(_ groupId: Data, userProfileWriter: UserProfileWriter, transaction: DBWriteTransaction) {
if let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) {
SSKEnvironment.shared.databaseStorageRef.touch(thread: groupThread, shouldReindex: false, tx: transaction)
}
transaction.addSyncCompletion {
// Mark the group for update
if OWSUserProfile.shouldUpdateStorageServiceForUserProfileWriter(userProfileWriter) {
self.recordPendingUpdatesForStorageService(groupId: groupId)
}
NotificationCenter.default.postOnMainThread(name: UserProfileNotifications.profileWhitelistDidChange, object: nil, userInfo: [
UserProfileNotifications.profileGroupIdKey: groupId,
Self.notificationKeyUserProfileWriter: NSNumber(value: userProfileWriter.rawValue),
])
}
}
private func recordPendingUpdatesForStorageService(groupId: Data) {
owsAssertDebug(!groupId.isEmpty)
SSKEnvironment.shared.databaseStorageRef.asyncRead { transaction in
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
owsFailDebug("Missing groupThread.")
return
}
SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(groupModel: groupThread.groupModel)
}
}
public func isGroupId(inProfileWhitelist groupId: Data, transaction: DBReadTransaction) -> Bool {
owsAssertDebug(!groupId.isEmpty)
if SSKEnvironment.shared.blockingManagerRef.isGroupIdBlocked_deprecated(groupId, tx: transaction) {
return false
}
let groupIdKey = groupKey(groupId: groupId)
return whitelistedGroupsStore.hasValue(groupIdKey, transaction: transaction)
}
// MARK: Other User's Profiles
public func localUserProfile(tx: DBReadTransaction) -> OWSUserProfile? {
return OWSUserProfile.getUserProfile(for: .localUser, tx: tx)
}
public func userProfile(for addressParam: SignalServiceAddress, tx: DBReadTransaction) -> OWSUserProfile? {
_getUserProfile(for: addressParam, tx: tx)
}
public func rotateProfileKeyUponRecipientHide(withTx tx: DBWriteTransaction) {
rotateProfileKeyUponRecipientHideObjC(tx: tx)
}
// MARK: - Notifications
@objc
@MainActor
private func applicationDidBecomeActive(_ notification: NSNotification) {
// TODO: Sync if necessary.
updateProfileOnServiceIfNecessary(authedAccount: .implicit())
}
@objc
@MainActor
private func reachabilityChanged(_ notification: NSNotification) {
updateProfileOnServiceIfNecessary(authedAccount: .implicit())
}
@objc
private func blockListDidChange(_ notification: NSNotification) {
AssertIsOnMainThread()
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
self.rotateLocalProfileKeyIfNecessary()
}
}
// MARK: - Clean Up
public static func allProfileAvatarFilePaths(transaction: DBReadTransaction) -> Set<String> {
OWSUserProfile.allProfileAvatarFilePaths(tx: transaction)
}
// MARK: - Profile Key Rotation
public func forceRotateLocalProfileKeyForGroupDeparture(with transaction: DBWriteTransaction) {
_forceRotateLocalProfileKeyForGroupDeparture(tx: transaction)
}
public func groupKey(groupId: Data) -> String {
groupId.hexadecimalString
}
}
extension OWSProfileManager: ProfileManager {
public func warmCaches() {
// Ensure we have a local profile for the current user.
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
if databaseStorage.read(block: localUserProfile(tx:)) == nil {
databaseStorage.write { tx in
_ = OWSUserProfile.getOrBuildUserProfileForLocalUser(userProfileWriter: .localUser, tx: tx)
}
}
}
public func fetchLocalUsersProfile(authedAccount: AuthedAccount) async throws -> FetchedProfile {
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
return try await profileFetcher.fetchProfile(
for: tsAccountManager.localIdentifiersWithMaybeSneakyTransaction(authedAccount: authedAccount).aci,
authedAccount: authedAccount,
)
}
public func updateProfile(
address: OWSUserProfile.InsertableAddress,
decryptedProfile: DecryptedProfile?,
avatarUrlPath: OptionalChange<String?>,
avatarFileName: OptionalChange<String?>,
profileBadges: [OWSUserProfileBadgeInfo],
lastFetchDate: Date,
userProfileWriter: UserProfileWriter,
tx: DBWriteTransaction,
) {
AssertNotOnMainThread()
let userProfile = OWSUserProfile.getOrBuildUserProfile(
for: address,
userProfileWriter: userProfileWriter,
tx: tx,
)
var givenNameChange: OptionalChange<String> = .noChange
var familyNameChange: OptionalChange<String?> = .noChange
var bioChange: OptionalChange<String?> = .noChange
var bioEmojiChange: OptionalChange<String?> = .noChange
var isPhoneNumberSharedChange: OptionalChange<Bool?> = .noChange
if let decryptedProfile {
do {
if let nameComponents = try decryptedProfile.nameComponents.get() {
givenNameChange = .setTo(nameComponents.givenName)
familyNameChange = .setTo(nameComponents.familyName)
}
} catch {
Logger.warn("Couldn't decrypt profile name: \(error)")
}
do {
bioChange = .setTo(try decryptedProfile.bio.get())
} catch {
Logger.warn("Couldn't decrypt profile bio: \(error)")
}
do {
bioEmojiChange = .setTo(try decryptedProfile.bioEmoji.get())
} catch {
Logger.warn("Couldn't decrypt profile bio emoji: \(error)")
}
do {
isPhoneNumberSharedChange = .setTo(try decryptedProfile.phoneNumberSharing.get())
} catch {
Logger.warn("Couldn't decrypt phone number sharing: \(error)")
}
}
userProfile.update(
givenName: givenNameChange.map({ $0 as String? }),
familyName: familyNameChange,
bio: bioChange,
bioEmoji: bioEmojiChange,
avatarUrlPath: avatarUrlPath,
avatarFileName: avatarFileName,
lastFetchDate: .setTo(lastFetchDate),
badges: .setTo(profileBadges),
isPhoneNumberShared: isPhoneNumberSharedChange,
userProfileWriter: userProfileWriter,
transaction: tx,
)
}
// The main entry point for updating the local profile. It will:
//
// * Enqueue a service update.
// * Attempt that service update.
//
// The returned promise will fail if the service can't be updated
// (or in the unlikely fact that another error occurs) but this
// manager will continue to retry until the update succeeds.
public func updateLocalProfile(
profileGivenName: OptionalChange<OWSUserProfile.NameComponent>,
profileFamilyName: OptionalChange<OWSUserProfile.NameComponent?>,
profileBio: OptionalChange<String?>,
profileBioEmoji: OptionalChange<String?>,
profileAvatarData: OptionalAvatarChange<Data?>,
visibleBadgeIds: OptionalChange<[String]>,
unsavedRotatedProfileKey: Aes256Key?,
userProfileWriter: UserProfileWriter,
authedAccount: AuthedAccount,
tx: DBWriteTransaction,
) -> Promise<Void> {
assert(CurrentAppContext().isMainApp)
let update = enqueueProfileUpdate(
profileGivenName: profileGivenName,
profileFamilyName: profileFamilyName,
profileBio: profileBio,
profileBioEmoji: profileBioEmoji,
profileAvatarData: profileAvatarData,
visibleBadgeIds: visibleBadgeIds,
userProfileWriter: userProfileWriter,
tx: tx,
)
let (promise, future) = Promise<Void>.pending()
pendingUpdateRequests.update {
$0.append(ProfileUpdateRequest(
requestId: update.id,
requestParameters: .init(profileKey: unsavedRotatedProfileKey, future: future),
authedAccount: authedAccount,
))
}
tx.addSyncCompletion {
Task { @MainActor in
self._updateProfileOnServiceIfNecessary()
}
}
return promise
}
// This will re-upload the existing local profile state.
public func reuploadLocalProfile(
unsavedRotatedProfileKey: Aes256Key?,
mustReuploadAvatar: Bool,
authedAccount: AuthedAccount,
tx: DBWriteTransaction,
) -> Promise<Void> {
Logger.info("")
let profileChanges = currentPendingProfileChanges(tx: tx)
return updateLocalProfile(
profileGivenName: .noChange,
profileFamilyName: .noChange,
profileBio: .noChange,
profileBioEmoji: .noChange,
profileAvatarData: mustReuploadAvatar ? .noChangeButMustReupload : .noChange,
visibleBadgeIds: .noChange,
unsavedRotatedProfileKey: unsavedRotatedProfileKey,
userProfileWriter: profileChanges?.userProfileWriter ?? .reupload,
authedAccount: authedAccount,
tx: tx,
)
}
// MARK: -
public func allWhitelistedAddresses(tx: DBReadTransaction) -> [SignalServiceAddress] {
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
return recipientStore.fetchWhitelistedRecipients(tx: tx).map(\.address)
}
public func allWhitelistedRegisteredAddresses(tx: DBReadTransaction) -> [SignalServiceAddress] {
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
return recipientStore.fetchWhitelistedRecipients(tx: tx).lazy.filter(\.isRegistered).map(\.address)
}
// MARK: -
func rotateLocalProfileKeyIfNecessary() {
DispatchQueue.global().async {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice else {
return
}
SSKEnvironment.shared.databaseStorageRef.write { tx in
self.rotateProfileKeyIfNecessary(tx: tx)
}
}
}
private func rotateProfileKeyIfNecessary(tx: DBWriteTransaction) {
if CurrentAppContext().isNSE || !appReadiness.isAppReady {
return
}
let tsRegistrationState = DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx)
guard
tsRegistrationState.isRegisteredPrimaryDevice
else {
owsFailDebug("Not rotating profile key on unregistered and/or non-primary device")
return
}
let lastGroupProfileKeyCheckTimestamp = self.lastGroupProfileKeyCheckTimestamp(tx: tx)
let triggers = [
self.blocklistRotationTriggerIfNeeded(tx: tx),
self.tokenTriggerIfNeeded(tx: tx),
].compacted()
guard !triggers.isEmpty else {
// No need to rotate the profile key.
if tsRegistrationState.isPrimaryDevice ?? true {
// But if it's been more than a week since we checked that our groups are up to date, schedule that.
if -(lastGroupProfileKeyCheckTimestamp?.timeIntervalSinceNow ?? 0) > .week {
SSKEnvironment.shared.groupsV2Ref.scheduleAllGroupsV2ForProfileKeyUpdate(transaction: tx)
self.setLastGroupProfileKeyCheckTimestamp(tx: tx)
tx.addSyncCompletion {
SSKEnvironment.shared.groupsV2Ref.processProfileKeyUpdates()
}
}
}
return
}
tx.addSyncCompletion {
Task {
await self.rotateProfileKey(triggers: triggers, authedAccount: AuthedAccount.implicit())
}
}
}
private enum RotateProfileKeyTrigger {
/// We need to rotate because one or more whitelist members is also on the blocklist.
/// Those members may be phone numbers, ACIs, PNIs, or groupIds, which are provided.
/// Once rotation is complete, these members should be removed from the whitelist;
/// their presence in the whitelist _and_ blocklist is what durably determines a rotation
/// is needed, so prematurely removing them and then failing to rotate means we won't retry.
case blocklistChange(BlocklistChange)
struct BlocklistChange {
let recipientIds: [SignalRecipient.RowId]
let groupIds: [Data]
}
/// We save a token when scheduling a profile key rotation. We schedule
/// *another* rotation if the token changes before we finish.
case tokenData(Data)
}
private func blocklistRotationTriggerIfNeeded(tx: DBReadTransaction) -> RotateProfileKeyTrigger? {
let victimRecipientIds = self.blockedRecipientIdsInWhitelist(tx: tx)
let victimGroupIds = self.blockedGroupIDsInWhitelist(tx: tx)
if victimRecipientIds.isEmpty, victimGroupIds.isEmpty {
// No need to rotate the profile key.
return nil
}
return .blocklistChange(RotateProfileKeyTrigger.BlocklistChange(
recipientIds: victimRecipientIds,
groupIds: victimGroupIds,
))
}
private func tokenTriggerIfNeeded(tx: DBReadTransaction) -> RotateProfileKeyTrigger? {
// If it's not nil, we should rotate. After rotating, if it hasn't changed,
// we write nil, so presence is the only trigger.
guard let triggerToken = self.triggerToken(tx: tx) else {
return nil
}
return .tokenData(triggerToken)
}
@MainActor
private func rotateProfileKey(
triggers: [RotateProfileKeyTrigger],
authedAccount: AuthedAccount,
) async {
guard !isRotatingProfileKey else {
return
}
let needsAnotherRotation: Bool
do {
isRotatingProfileKey = true
defer {
isRotatingProfileKey = false
}
needsAnotherRotation = (try? await _rotateProfileKey(triggers: triggers, authedAccount: authedAccount)) ?? false
}
if needsAnotherRotation {
self.rotateLocalProfileKeyIfNecessary()
}
}
private func _rotateProfileKey(
triggers: [RotateProfileKeyTrigger],
authedAccount: AuthedAccount,
) async throws -> Bool {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
guard registeredState.isPrimary else {
throw OWSAssertionError("not a primary device")
}
Logger.info("Beginning profile key rotation.")
// The order of operations here is very important to prevent races between
// when we rotate our profile key and reupload our profile. It's essential
// we avoid a case where other devices are operating with a *new* profile
// key that we have yet to upload a profile for.
Logger.info("Reuploading profile with new profile key")
// We re-upload our local profile with the new profile key *before* we
// persist it. This is safe, because versioned profiles allow other clients
// to continue using our old profile key with the old version of our
// profile. It is possible this operation will fail, in which case we will
// try to rotate your profile key again on the next app launch or blocklist
// change and continue to use the old profile key.
let newProfileKey = Aes256Key.generateRandom()
let uploadPromise = await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
self.reuploadLocalProfile(
unsavedRotatedProfileKey: newProfileKey,
mustReuploadAvatar: true,
authedAccount: authedAccount,
tx: tx,
)
}
try await uploadPromise.awaitable()
let localAci = registeredState.localIdentifiers.aci
Logger.info("Persisting rotated profile key and kicking off subsequent operations.")
let needsAnotherRotation = await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
self.setLocalProfileKey(
newProfileKey,
userProfileWriter: .localUser,
transaction: tx,
)
// Whenever a user's profile key changes, we need to fetch a new profile
// key credential for them.
SSKEnvironment.shared.versionedProfilesRef.clearProfileKeyCredential(for: localAci, transaction: tx)
// We schedule the updates here but process them below using
// processProfileKeyUpdates. It's more efficient to process them after the
// intermediary steps are done.
SSKEnvironment.shared.groupsV2Ref.scheduleAllGroupsV2ForProfileKeyUpdate(transaction: tx)
var needsAnotherRotation = false
triggers.forEach { trigger in
switch trigger {
case .blocklistChange(let values):
self.didRotateProfileKeyFromBlocklistTrigger(values, tx: tx)
case .tokenData(let tokenData):
needsAnotherRotation = !self.clearTriggerToken(tokenData, tx: tx) || needsAnotherRotation
}
}
return needsAnotherRotation
}
Logger.info("Updating account attributes after profile key rotation.")
try await DependenciesBridge.shared.accountAttributesUpdater.updateAccountAttributes(authedAccount: authedAccount)
Logger.info("Completed profile key rotation.")
SSKEnvironment.shared.groupsV2Ref.processProfileKeyUpdates()
return needsAnotherRotation
}
private func didRotateProfileKeyFromBlocklistTrigger(
_ trigger: RotateProfileKeyTrigger.BlocklistChange,
tx: DBWriteTransaction,
) {
// It's absolutely essential that these values are persisted in the same transaction
// in which we persist our new profile key, since storing them is what marks the
// profile key rotation as "complete" (removing newly blocked users from the whitelist).
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
trigger.recipientIds.forEach { recipientId in
let recipient = recipientStore.fetchRecipient(rowId: recipientId, tx: tx)
guard var recipient else {
return
}
recipient.status = .unspecified
recipientStore.updateRecipient(recipient, transaction: tx)
}
self.whitelistedGroupsStore.removeValues(
forKeys: trigger.groupIds.map { self.groupKey(groupId: $0) },
transaction: tx,
)
}
// Returns true if the trigger was cleared.
private func clearTriggerToken(_ tokenData: Data, tx: DBWriteTransaction) -> Bool {
// Fetch the latest trigger date, it might have changed if we triggered
// a rotation again.
guard tokenData == self.triggerToken(tx: tx) else {
return false
}
self.setTriggerToken(nil, tx: tx)
return true
}
private func blockedRecipientIdsInWhitelist(tx: DBReadTransaction) -> [SignalRecipient.RowId] {
let blockingManager = SSKEnvironment.shared.blockingManagerRef
let recipientStore = DependenciesBridge.shared.recipientDatabaseTable
return blockingManager.blockedRecipientIds(tx: tx).filter {
return recipientStore.fetchRecipient(rowId: $0, tx: tx)!.status == .whitelisted
}
}
private func blockedGroupIDsInWhitelist(tx: DBReadTransaction) -> [Data] {
let allWhitelistedGroupKeys = whitelistedGroupsStore.allKeys(transaction: tx)
return allWhitelistedGroupKeys.lazy
.compactMap { self.groupIdForGroupKey($0) }
.filter { SSKEnvironment.shared.blockingManagerRef.isGroupIdBlocked_deprecated($0, tx: tx) }
}
private func groupIdForGroupKey(_ groupKey: String) -> Data? {
guard let groupId = Data.data(fromHex: groupKey) else { return nil }
if GroupManager.isValidGroupIdOfAnyKind(groupId) {
return groupId
} else {
owsFailDebug("Parsed group id has unexpected length: \(groupId.hexadecimalString) (\(groupId.count))")
return nil
}
}
class func updateStorageServiceIfNecessary() {
guard
CurrentAppContext().isMainApp,
DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice
else {
return
}
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
let hasUpdated = databaseStorage.read { transaction in
storageServiceStore.getBool(
Self.hasUpdatedStorageServiceKey,
defaultValue: false,
transaction: transaction,
)
}
guard !hasUpdated else {
return
}
let profileManager = SSKEnvironment.shared.profileManagerRef
let userProfile = databaseStorage.read(block: profileManager.localUserProfile(tx:))
if userProfile?.loadAvatarData() != nil {
Logger.info("Scheduling a backup.")
// Schedule a backup.
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
}
databaseStorage.write { transaction in
storageServiceStore.setBool(
true,
key: Self.hasUpdatedStorageServiceKey,
transaction: transaction,
)
}
}
private static let storageServiceStore = KeyValueStore(collection: "OWSProfileManager.storageServiceStore")
private static let hasUpdatedStorageServiceKey = "hasUpdatedStorageServiceKey"
// MARK: - Other User's Profiles
/// Updates the profile key, optionally fetching the latest profile.
public func setProfileKeyData(
_ profileKeyData: Data,
for serviceId: ServiceId,
onlyFillInIfMissing: Bool,
shouldFetchProfile: Bool,
userProfileWriter: UserProfileWriter,
localIdentifiers: LocalIdentifiers,
authedAccount: AuthedAccount,
tx: DBWriteTransaction,
) {
let address = OWSUserProfile.insertableAddress(serviceId: serviceId, localIdentifiers: localIdentifiers)
guard let profileKey = Aes256Key(data: profileKeyData) else {
owsFailDebug("Invalid profile key data for \(serviceId).")
return
}
let userProfile = OWSUserProfile.getOrBuildUserProfile(
for: address,
userProfileWriter: userProfileWriter,
tx: tx,
)
if onlyFillInIfMissing, userProfile.profileKey != nil {
return
}
if profileKey.keyData == userProfile.profileKey?.keyData {
return
}
if let aci = serviceId as? Aci {
// Whenever a user's profile key changes, we need to fetch a new
// profile key credential for them.
SSKEnvironment.shared.versionedProfilesRef.clearProfileKeyCredential(for: aci, transaction: tx)
}
// If this is the profile for the local user, we always want to defer to local state
// so skip the update profile for address call.
if case .otherUser(let serviceId) = address {
if let aci = serviceId as? Aci {
SSKEnvironment.shared.udManagerRef.setUnidentifiedAccessMode(.unknown, for: aci, tx: tx)
}
if shouldFetchProfile {
tx.addSyncCompletion {
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
_ = profileFetcher.fetchProfileSync(for: serviceId, authedAccount: authedAccount)
}
}
}
userProfile.update(
profileKey: .setTo(profileKey),
userProfileWriter: userProfileWriter,
transaction: tx,
)
}
public func fillInProfileKeys(
allProfileKeys: [Aci: Data],
authoritativeProfileKeys: [Aci: Data],
userProfileWriter: UserProfileWriter,
localIdentifiers: LocalIdentifiers,
tx: DBWriteTransaction,
) {
for (aci, profileKey) in authoritativeProfileKeys {
setProfileKeyData(
profileKey,
for: aci,
onlyFillInIfMissing: false,
shouldFetchProfile: true,
userProfileWriter: userProfileWriter,
localIdentifiers: localIdentifiers,
authedAccount: .implicit(),
tx: tx,
)
}
for (aci, profileKey) in allProfileKeys {
if authoritativeProfileKeys[aci] != nil {
continue
}
setProfileKeyData(
profileKey,
for: aci,
onlyFillInIfMissing: true,
shouldFetchProfile: true,
userProfileWriter: userProfileWriter,
localIdentifiers: localIdentifiers,
authedAccount: .implicit(),
tx: tx,
)
}
}
// MARK: - Bulk Fetching
func _getUserProfile(for address: SignalServiceAddress, tx: DBReadTransaction) -> OWSUserProfile? {
// TODO: Don't reach out to global state.
if address.isLocalAddress {
// For "local reads", use the local user profile.
return localUserProfile(tx: tx)
} else {
return OWSUserProfile.getUserProfiles(for: [.otherUser(address)], tx: tx).first!
}
}
public func fetchUserProfiles(for addresses: [SignalServiceAddress], tx: DBReadTransaction) -> [OWSUserProfile?] {
return Refinery<SignalServiceAddress, OWSUserProfile>(addresses).refine(condition: { address in
// TODO: Don't reach out to global state.
return address.isLocalAddress
}, then: { localAddresses in
lazy var profile = { self.localUserProfile(tx: tx) }()
return localAddresses.lazy.map { _ in profile }
}, otherwise: { otherAddresses in
return OWSUserProfile.getUserProfiles(for: otherAddresses.map { .otherUser($0) }, tx: tx)
}).values
}
// MARK: -
@MainActor
private func updateProfileOnServiceIfNecessary(authedAccount: AuthedAccount) {
switch _updateProfileOnServiceIfNecessary() {
case .notReady, .updating:
return
case .notNeeded:
repairAvatarIfNeeded(authedAccount: authedAccount)
}
}
private enum UpdateProfileStatus {
case notReady
case notNeeded
case updating
}
fileprivate struct ProfileUpdateRequest {
var requestId: UUID
var requestParameters: Parameters
var authedAccount: AuthedAccount
struct Parameters {
var profileKey: Aes256Key?
var future: Future<Void>
}
}
@discardableResult
@MainActor
private func _updateProfileOnServiceIfNecessary(retryDelay: TimeInterval = 1) -> UpdateProfileStatus {
guard appReadiness.isAppReady else {
return .notReady
}
guard !isUpdatingProfileOnService else {
// Avoid having two redundant updates in flight at the same time.
return .notReady
}
let profileChanges = SSKEnvironment.shared.databaseStorageRef.read { tx in
return currentPendingProfileChanges(tx: tx)
}
guard let profileChanges else {
return .notNeeded
}
guard let (parameters, authedAccount) = processProfileUpdateRequests(upThrough: profileChanges.id) else {
return .notReady
}
isUpdatingProfileOnService = true
let backgroundTask = OWSBackgroundTask(label: #function)
Task { @MainActor in
defer {
isUpdatingProfileOnService = false
backgroundTask.end()
}
do {
try await updateProfileOnService(
profileChanges: profileChanges,
newProfileKey: parameters?.profileKey,
authedAccount: authedAccount,
)
DispatchQueue.global().async {
parameters?.future.resolve()
}
DispatchQueue.main.async {
self._updateProfileOnServiceIfNecessary()
}
} catch {
DispatchQueue.global().async {
parameters?.future.reject(error)
}
Logger.warn("Retrying profile update after \(retryDelay)s due to error: \(error)")
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) {
self._updateProfileOnServiceIfNecessary(retryDelay: retryDelay * 2)
}
}
}
return .updating
}
/// Searches `pendingUpdateRequests` for a match; cancels dropped requests.
///
/// Processes `pendingUpdateRequests` while searching for `requestId`. If it
/// finds a pending request with `requestId`, it will cancel any preceding
/// requests that are now obsolete.
///
/// - Returns: The `Future` and `AuthedAccount` to use for `requestId`. If
/// `requestId` can't be sent right now, returns `nil`. We can't send
/// requests if we can't authenticate with the server. We assume we can
/// authenticate when we're registered or if the request's `authedAccount`
/// contains explicit credentials.
private func processProfileUpdateRequests(upThrough requestId: UUID) -> (ProfileUpdateRequest.Parameters?, AuthedAccount)? {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let isRegistered = tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered
// Find the AuthedAccount & Future for the request. This will generally
// exist for the initial request but will be nil for any retries.
let pendingRequests: (canceledRequests: [ProfileUpdateRequest], result: (ProfileUpdateRequest.Parameters?, AuthedAccount)?)
pendingRequests = pendingUpdateRequests.update { mutableRequests in
let canceledRequests: [ProfileUpdateRequest]
let currentRequest: ProfileUpdateRequest?
if let requestIndex = mutableRequests.firstIndex(where: { $0.requestId == requestId }) {
canceledRequests = Array(mutableRequests.prefix(upTo: requestIndex))
currentRequest = mutableRequests[requestIndex]
mutableRequests = Array(mutableRequests[requestIndex...])
} else {
canceledRequests = []
currentRequest = nil
}
let authedAccount = currentRequest?.authedAccount ?? .implicit()
switch authedAccount.info {
case .implicit where isRegistered, .explicit:
mutableRequests = Array(mutableRequests.dropFirst())
return (canceledRequests, (currentRequest?.requestParameters, authedAccount))
case .implicit:
return (canceledRequests, nil)
}
}
for canceledRequest in pendingRequests.canceledRequests {
canceledRequest.requestParameters.future.reject(OWSGenericError("Canceled because a new request was enqueued."))
}
return pendingRequests.result
}
@MainActor
private static var avatarRepairPromise: Promise<Void>?
@MainActor
private func repairAvatarIfNeeded(authedAccount: AuthedAccount) {
guard CurrentAppContext().isMainApp else {
return
}
dispatchPrecondition(condition: .onQueue(.main))
guard Self.avatarRepairPromise == nil else {
// Already have a repair outstanding.
return
}
// Have already repaired?
guard avatarRepairNeeded() else {
return
}
guard
DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice ?? false,
SSKEnvironment.shared.databaseStorageRef.read(block: SSKEnvironment.shared.profileManagerRef.localUserProfile(tx:))?.loadAvatarData() != nil
else {
SSKEnvironment.shared.databaseStorageRef.write { tx in clearAvatarRepairNeeded(tx: tx) }
return
}
Self.avatarRepairPromise = Promise.wrapAsync { @MainActor in
defer {
Self.avatarRepairPromise = nil
}
do {
let uploadPromise = await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
return self.reuploadLocalProfile(
unsavedRotatedProfileKey: nil,
mustReuploadAvatar: true,
authedAccount: authedAccount,
tx: tx,
)
}
try await uploadPromise.awaitable()
Logger.info("Avatar repair succeeded.")
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in self.clearAvatarRepairNeeded(tx: tx) }
} catch {
Logger.warn("Avatar repair failed: \(error)")
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in self.incrementAvatarRepairAttempts(tx: tx) }
}
}
}
private static let maxAvatarRepairAttempts = 5
private func avatairRepairAttemptCount(_ transaction: DBReadTransaction) -> Int {
let store = KeyValueStore(collection: GRDBSchemaMigrator.migrationSideEffectsCollectionName)
return store.getInt(
GRDBSchemaMigrator.avatarRepairAttemptCount,
defaultValue: Self.maxAvatarRepairAttempts,
transaction: transaction,
)
}
private func avatarRepairNeeded() -> Bool {
return SSKEnvironment.shared.databaseStorageRef.read { transaction in
let count = avatairRepairAttemptCount(transaction)
return count < Self.maxAvatarRepairAttempts
}
}
private func incrementAvatarRepairAttempts(tx: DBWriteTransaction) {
let store = KeyValueStore(collection: GRDBSchemaMigrator.migrationSideEffectsCollectionName)
store.setInt(avatairRepairAttemptCount(tx) + 1, key: GRDBSchemaMigrator.avatarRepairAttemptCount, transaction: tx)
}
private func clearAvatarRepairNeeded(tx: DBWriteTransaction) {
let store = KeyValueStore(collection: GRDBSchemaMigrator.migrationSideEffectsCollectionName)
store.removeValue(forKey: GRDBSchemaMigrator.avatarRepairAttemptCount, transaction: tx)
}
private func updateProfileOnService(
profileChanges: PendingProfileUpdate,
newProfileKey: Aes256Key?,
authedAccount: AuthedAccount,
) async throws {
do {
let userProfile = SSKEnvironment.shared.databaseStorageRef.read(
block: SSKEnvironment.shared.profileManagerImplRef.localUserProfile(tx:),
)
guard let userProfile else {
throw OWSAssertionError("Can't upload profile without profile.")
}
let avatarUpdate = try await buildAvatarUpdate(
avatarChange: profileChanges.profileAvatarData,
localUserProfile: userProfile,
authedAccount: authedAccount,
)
guard let profileKey = newProfileKey ?? userProfile.profileKey else {
throw OWSAssertionError("Can't upload profile without profileKey.")
}
// It's important that the value we compute for
// `newGivenName`/`newFamilyName` exactly match what we put in our
// encrypted profile. If they don't match (eg one trims the value and the
// other doesn't), then `LocalProfileChecker` may enter an infinite loop
// trying to fix the profile.
let newGivenName: OWSUserProfile.NameComponent?
switch profileChanges.profileGivenName {
case .setTo(let newValue):
newGivenName = newValue
case .noChange:
// This is the *only* place that can clear our own name, and it only does
// so when the name we think we have (which should be valid) isn't valid.
newGivenName = userProfile.givenName.flatMap { OWSUserProfile.NameComponent(truncating: $0) }
}
let newFamilyName = profileChanges.profileFamilyName.orExistingValue(
userProfile.familyName.flatMap { OWSUserProfile.NameComponent(truncating: $0) },
)
let newBio = profileChanges.profileBio.orExistingValue(userProfile.bio)
let newBioEmoji = profileChanges.profileBioEmoji.orExistingValue(userProfile.bioEmoji)
let newVisibleBadgeIds = profileChanges.visibleBadgeIds.orExistingValue(userProfile.visibleBadges.map { $0.badgeId })
let versionedUpdate = try await SSKEnvironment.shared.versionedProfilesRef.updateProfile(
profileGivenName: newGivenName,
profileFamilyName: newFamilyName,
profileBio: newBio,
profileBioEmoji: newBioEmoji,
profileAvatarMutation: avatarUpdate.remoteMutation,
visibleBadgeIds: newVisibleBadgeIds,
profileKey: profileKey,
authedAccount: authedAccount,
)
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
self.tryToDequeueProfileChanges(profileChanges, tx: tx)
// Apply the changes to our local profile.
let userProfile = OWSUserProfile.getOrBuildUserProfile(
for: .localUser,
userProfileWriter: .localUser,
tx: tx,
)
userProfile.update(
givenName: .setTo(newGivenName?.stringValue.rawValue),
familyName: .setTo(newFamilyName?.stringValue.rawValue),
bio: .setTo(newBio),
bioEmoji: .setTo(newBioEmoji),
avatarUrlPath: .setTo(versionedUpdate.avatarUrlPath.orExistingValue(userProfile.avatarUrlPath)),
avatarFileName: .setTo(avatarUpdate.filenameChange.orExistingValue(userProfile.avatarFileName)),
userProfileWriter: profileChanges.userProfileWriter,
transaction: tx,
)
// Notify all our devices that the profile has changed.
let tsRegistrationState = DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx)
if tsRegistrationState.isRegistered {
SSKEnvironment.shared.syncManagerRef.sendFetchLatestProfileSyncMessage(tx: tx)
}
}
// If we're changing the profile key, then the normal "fetch our profile"
// method will fail because it will the just-obsoleted profile key.
if newProfileKey == nil {
_ = try await fetchLocalUsersProfile(authedAccount: authedAccount)
}
} catch let error where error.isNetworkFailureOrTimeout {
// We retry network errors forever (with exponential backoff).
throw error
} catch {
// Other errors cause us to give up immediately.
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in self.tryToDequeueProfileChanges(profileChanges, tx: tx) }
Logger.error("Discarding profile update due to fatal error: \(error)")
throw error
}
}
/// Computes necessary changes to the avatar.
///
/// Most fields are re-encrypted with every update. However, the avatar can
/// be large, so we only update it if absolutely necessary. We must update
/// the profile avatar in three cases:
/// (1) The avatar has changed (duh!).
/// (2) The profile key has changed.
/// (3) The remote avatar doesn't match the local avatar.
/// In the first case, we'll always have the new avatar because we're
/// actively changing it. In the latter case, another device may have
/// changed it, so we may need to download it to re-encrypt it.
private func buildAvatarUpdate(
avatarChange: OptionalAvatarChange<Data?>,
localUserProfile: OWSUserProfile,
authedAccount: AuthedAccount,
) async throws -> (remoteMutation: VersionedProfileAvatarMutation, filenameChange: OptionalChange<String?>) {
switch avatarChange {
case .setTo(let newAvatar):
// This is case (1). It's also case (2) when we're also changing the avatar.
if let newAvatar {
let avatarFilename = try writeProfileAvatarToDisk(avatarData: newAvatar)
return (.changeAvatar(newAvatar), .setTo(avatarFilename))
}
return (.clearAvatar, .setTo(nil))
case .noChangeButMustReupload:
// This is case (2) and (3).
do {
try await downloadAndDecryptLocalUserAvatarIfNeeded(authedAccount: authedAccount)
} catch where !error.isNetworkFailureOrTimeout {
// Ignore the error because it's not likely to go away if we retry. If we
// can't decrypt the existing avatar, then we don't really have any choice
// other than blowing it away.
Logger.warn("Dropping unfetchable avatar: \(error)")
}
if let avatarFilename = localUserProfile.avatarFileName, let avatarData = localUserProfile.loadAvatarData() {
return (.changeAvatar(avatarData), .setTo(avatarFilename))
}
return (.clearAvatar, .setTo(nil))
case .noChange:
// We aren't changing the avatar, so use the existing value.
if localUserProfile.avatarUrlPath != nil {
return (.keepAvatar, .noChange)
}
return (.clearAvatar, .setTo(nil))
}
}
private func writeProfileAvatarToDisk(avatarData: Data) throws -> String {
let filename = OWSUserProfile.generateAvatarFilename()
let filePath = OWSUserProfile.profileAvatarFilePath(for: filename)
do {
try avatarData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
} catch {
throw OWSError(error: .avatarWriteFailed, description: "Avatar write failed.", isRetryable: false)
}
return filename
}
// MARK: - Update Queue
private static let kPendingProfileUpdateKey = "kPendingProfileUpdateKey"
private func enqueueProfileUpdate(
profileGivenName: OptionalChange<OWSUserProfile.NameComponent>,
profileFamilyName: OptionalChange<OWSUserProfile.NameComponent?>,
profileBio: OptionalChange<String?>,
profileBioEmoji: OptionalChange<String?>,
profileAvatarData: OptionalAvatarChange<Data?>,
visibleBadgeIds: OptionalChange<[String]>,
userProfileWriter: UserProfileWriter,
tx: DBWriteTransaction,
) -> PendingProfileUpdate {
let oldChanges = currentPendingProfileChanges(tx: tx)
let newChanges = PendingProfileUpdate(
profileGivenName: profileGivenName.orElseIfNoChange(oldChanges?.profileGivenName ?? .noChange),
profileFamilyName: profileFamilyName.orElseIfNoChange(oldChanges?.profileFamilyName ?? .noChange),
profileBio: profileBio.orElseIfNoChange(oldChanges?.profileBio ?? .noChange),
profileBioEmoji: profileBioEmoji.orElseIfNoChange(oldChanges?.profileBioEmoji ?? .noChange),
profileAvatarData: { () -> OptionalAvatarChange<Data?> in
let newValue = profileAvatarData
// If newValue isn't as important as oldValue, prefer oldValue. If there's
// a tie, prefer newValue.
if let oldValue = oldChanges?.profileAvatarData, newValue.isLessImportantThan(oldValue) {
return oldValue
}
return newValue
}(),
visibleBadgeIds: visibleBadgeIds.orElseIfNoChange(oldChanges?.visibleBadgeIds ?? .noChange),
userProfileWriter: userProfileWriter,
)
settingsStore.setObject(newChanges, key: Self.kPendingProfileUpdateKey, transaction: tx)
return newChanges
}
private func currentPendingProfileChanges(tx: DBReadTransaction) -> PendingProfileUpdate? {
return settingsStore.getObject(Self.kPendingProfileUpdateKey, ofClass: PendingProfileUpdate.self, transaction: tx)
}
private func isCurrentPendingProfileChanges(_ profileChanges: PendingProfileUpdate, tx: DBReadTransaction) -> Bool {
guard let databaseValue = currentPendingProfileChanges(tx: tx) else {
return false
}
return profileChanges.hasSameIdAs(databaseValue)
}
private func tryToDequeueProfileChanges(_ profileChanges: PendingProfileUpdate, tx: DBWriteTransaction) {
guard isCurrentPendingProfileChanges(profileChanges, tx: tx) else {
Logger.warn("Ignoring stale update completion.")
return
}
settingsStore.removeValue(forKey: Self.kPendingProfileUpdateKey, transaction: tx)
}
/// Rotates the local profile key. Intended specifically
/// for the use case of recipient hiding.
///
/// - Parameter tx: The transaction to use for this operation.
func rotateProfileKeyUponRecipientHideObjC(tx: DBWriteTransaction) {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let registeredState = try? tsAccountManager.registeredState(tx: tx) else {
return
}
guard registeredState.isPrimary else {
return
}
// We schedule in the NSE by writing state; the actual rotation
// will bail early, though.
self.setTriggerToken(Randomness.generateRandomBytes(16), tx: tx)
self.rotateProfileKeyIfNecessary(tx: tx)
}
fileprivate func _forceRotateLocalProfileKeyForGroupDeparture(tx: DBWriteTransaction) {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard let registeredState = try? tsAccountManager.registeredState(tx: tx) else {
return
}
guard registeredState.isPrimary else {
return
}
// We schedule in the NSE by writing state; the actual rotation
// will bail early, though.
self.setTriggerToken(Randomness.generateRandomBytes(16), tx: tx)
self.rotateProfileKeyIfNecessary(tx: tx)
}
// MARK: - Profile Key Rotation Metadata
private static let kLastGroupProfileKeyCheckTimestampKey = "lastGroupProfileKeyCheckTimestamp"
private func lastGroupProfileKeyCheckTimestamp(tx: DBReadTransaction) -> Date? {
return self.metadataStore.getDate(Self.kLastGroupProfileKeyCheckTimestampKey, transaction: tx)
}
private func setLastGroupProfileKeyCheckTimestamp(tx: DBWriteTransaction) {
return self.metadataStore.setDate(Date(), key: Self.kLastGroupProfileKeyCheckTimestampKey, transaction: tx)
}
private static let leaveGroupTriggerTokenKey = "leaveGroupTriggerTimestampKey"
private static let deprecated_recipientHidingTriggerTokenKey = "recipientHidingTriggerTimestampKey"
private func triggerToken(tx: DBReadTransaction) -> Data? {
return
self.metadataStore.getData(Self.leaveGroupTriggerTokenKey, transaction: tx)
?? self.metadataStore.getData(Self.deprecated_recipientHidingTriggerTokenKey, transaction: tx)
}
private func setTriggerToken(_ tokenData: Data?, tx: DBWriteTransaction) {
if let tokenData {
self.metadataStore.setData(tokenData, key: Self.leaveGroupTriggerTokenKey, transaction: tx)
} else {
self.metadataStore.removeValue(forKey: Self.leaveGroupTriggerTokenKey, transaction: tx)
}
self.metadataStore.removeValue(forKey: Self.deprecated_recipientHidingTriggerTokenKey, transaction: tx)
}
// MARK: - Last Messaging Date
public func didSendOrReceiveMessage(
serviceId: ServiceId,
localIdentifiers: LocalIdentifiers,
tx: DBWriteTransaction,
) {
let userProfileWriter: UserProfileWriter = .metadataUpdate
let address = OWSUserProfile.insertableAddress(serviceId: serviceId, localIdentifiers: localIdentifiers)
switch address {
case .localUser:
return
case .otherUser:
break
case .legacyUserPhoneNumberFromBackupRestore:
owsFail("Impossible: could not get this case when constructing an insertable address from a service ID.")
}
let userProfile = OWSUserProfile.getOrBuildUserProfile(
for: address,
userProfileWriter: userProfileWriter,
tx: tx,
)
// lastMessagingDate is coarse; we don't need to track every single message
// sent or received. It is sufficient to update it only when the value
// changes by more than an hour.
if let lastMessagingDate = userProfile.lastMessagingDate, abs(lastMessagingDate.timeIntervalSinceNow) < .hour {
return
}
userProfile.update(
lastMessagingDate: .setTo(Date()),
userProfileWriter: userProfileWriter,
transaction: tx,
)
}
}
// MARK: -
public class PendingProfileUpdate: NSObject, NSSecureCoding {
let id: UUID
let profileGivenName: OptionalChange<OWSUserProfile.NameComponent>
let profileFamilyName: OptionalChange<OWSUserProfile.NameComponent?>
let profileBio: OptionalChange<String?>
let profileBioEmoji: OptionalChange<String?>
let profileAvatarData: OptionalAvatarChange<Data?>
let visibleBadgeIds: OptionalChange<[String]>
let userProfileWriter: UserProfileWriter
init(
profileGivenName: OptionalChange<OWSUserProfile.NameComponent>,
profileFamilyName: OptionalChange<OWSUserProfile.NameComponent?>,
profileBio: OptionalChange<String?>,
profileBioEmoji: OptionalChange<String?>,
profileAvatarData: OptionalAvatarChange<Data?>,
visibleBadgeIds: OptionalChange<[String]>,
userProfileWriter: UserProfileWriter,
) {
self.id = UUID()
self.profileGivenName = profileGivenName
self.profileFamilyName = profileFamilyName
self.profileBio = profileBio
self.profileBioEmoji = profileBioEmoji
self.profileAvatarData = Self.normalizeAvatar(profileAvatarData)
self.visibleBadgeIds = visibleBadgeIds
self.userProfileWriter = userProfileWriter
}
func hasSameIdAs(_ other: PendingProfileUpdate) -> Bool {
return self.id == other.id
}
private static func normalizeAvatar(_ avatarData: OptionalAvatarChange<Data?>) -> OptionalAvatarChange<Data?> {
return avatarData.map { $0?.nilIfEmpty }
}
// MARK: - NSSecureCoding
public class var supportsSecureCoding: Bool { true }
private enum NSCodingKeys: String {
case id
case profileGivenName
case profileFamilyName
case profileBio
case profileBioEmoji
case profileAvatarData
case visibleBadgeIds
case userProfileWriter
var changedKey: String { rawValue + "Changed" }
var mustReuploadKey: String { rawValue + "MustReupload" }
}
private static func encodeOptionalChange<T>(_ value: OptionalChange<T?>, for codingKey: NSCodingKeys, with aCoder: NSCoder) {
switch value {
case .noChange:
aCoder.encode(false, forKey: codingKey.changedKey)
case .setTo(let value):
if let value {
aCoder.encode(value, forKey: codingKey.rawValue)
}
}
}
private static func encodeOptionalAvatarChange<T>(_ value: OptionalAvatarChange<T?>, for codingKey: NSCodingKeys, with aCoder: NSCoder) {
switch value {
case .noChangeButMustReupload:
aCoder.encode(true, forKey: codingKey.mustReuploadKey)
fallthrough
case .noChange:
aCoder.encode(false, forKey: codingKey.changedKey)
case .setTo(let value):
if let value {
aCoder.encode(value, forKey: codingKey.rawValue)
}
}
}
private static func encodeVisibleBadgeIds(_ value: OptionalChange<[String]>, for codingKey: NSCodingKeys, with aCoder: NSCoder) {
switch value {
case .noChange:
break
case .setTo(let value):
aCoder.encode(value, forKey: codingKey.rawValue)
}
}
public func encode(with aCoder: NSCoder) {
aCoder.encode(id.uuidString, forKey: NSCodingKeys.id.rawValue)
Self.encodeOptionalChange(profileGivenName.map { $0.stringValue.rawValue }, for: .profileGivenName, with: aCoder)
Self.encodeOptionalChange(profileFamilyName.map { $0?.stringValue.rawValue }, for: .profileFamilyName, with: aCoder)
Self.encodeOptionalChange(profileBio, for: .profileBio, with: aCoder)
Self.encodeOptionalChange(profileBioEmoji, for: .profileBioEmoji, with: aCoder)
Self.encodeOptionalAvatarChange(profileAvatarData, for: .profileAvatarData, with: aCoder)
Self.encodeVisibleBadgeIds(visibleBadgeIds, for: .visibleBadgeIds, with: aCoder)
aCoder.encodeCInt(Int32(userProfileWriter.rawValue), forKey: NSCodingKeys.userProfileWriter.rawValue)
}
private static func decodeOptionalChange<T: NSObject & NSSecureCoding>(of cls: T.Type, for codingKey: NSCodingKeys, with aDecoder: NSCoder) -> OptionalChange<T?> {
if aDecoder.containsValue(forKey: codingKey.changedKey), !aDecoder.decodeBool(forKey: codingKey.changedKey) {
return .noChange
}
return .setTo(aDecoder.decodeObject(of: cls, forKey: codingKey.rawValue) as T?)
}
private static func decodeOptionalAvatarChange(for codingKey: NSCodingKeys, with aDecoder: NSCoder) -> OptionalAvatarChange<Data?> {
if aDecoder.containsValue(forKey: codingKey.changedKey), !aDecoder.decodeBool(forKey: codingKey.changedKey) {
if aDecoder.containsValue(forKey: codingKey.mustReuploadKey), aDecoder.decodeBool(forKey: codingKey.mustReuploadKey) {
return .noChangeButMustReupload
}
return .noChange
}
return .setTo(aDecoder.decodeObject(of: NSData.self, forKey: codingKey.rawValue) as Data?)
}
private static func decodeOptionalNameChange(
for codingKey: NSCodingKeys,
with aDecoder: NSCoder,
) -> OptionalChange<OWSUserProfile.NameComponent?> {
let stringChange = decodeOptionalChange(of: NSString.self, for: codingKey, with: aDecoder)
switch stringChange {
case .noChange:
return .noChange
case .setTo(.none):
return .setTo(nil)
case .setTo(.some(let value)):
// We shouldn't be able to encode an invalid value. If we do, fall back to
// `.noChange` rather than clearing it.
guard let nameComponent = OWSUserProfile.NameComponent(truncating: value as String) else {
return .noChange
}
return .setTo(nameComponent)
}
}
private static func decodeRequiredNameChange(
for codingKey: NSCodingKeys,
with aDecoder: NSCoder,
) -> OptionalChange<OWSUserProfile.NameComponent> {
switch decodeOptionalNameChange(for: codingKey, with: aDecoder) {
case .noChange:
return .noChange
case .setTo(.none):
// Don't allow the value to be cleared. Fall back to `.noChange` rather
// than throwing an error.
return .noChange
case .setTo(.some(let value)):
return .setTo(value)
}
}
private static func decodeVisibleBadgeIds(for codingKey: NSCodingKeys, with aDecoder: NSCoder) -> OptionalChange<[String]> {
guard let visibleBadgeIds = aDecoder.decodeArrayOfObjects(ofClass: NSString.self, forKey: codingKey.rawValue) as [String]? else {
return .noChange
}
return .setTo(visibleBadgeIds)
}
public required init?(coder aDecoder: NSCoder) {
guard
let idString = aDecoder.decodeObject(of: NSString.self, forKey: NSCodingKeys.id.rawValue) as String?,
let id = UUID(uuidString: idString)
else {
owsFailDebug("Missing id")
return nil
}
self.id = id
self.profileGivenName = Self.decodeRequiredNameChange(for: .profileGivenName, with: aDecoder)
self.profileFamilyName = Self.decodeOptionalNameChange(for: .profileFamilyName, with: aDecoder)
self.profileBio = Self.decodeOptionalChange(of: NSString.self, for: .profileBio, with: aDecoder).map({ $0 as String? })
self.profileBioEmoji = Self.decodeOptionalChange(of: NSString.self, for: .profileBioEmoji, with: aDecoder).map({ $0 as String? })
self.profileAvatarData = Self.normalizeAvatar(Self.decodeOptionalAvatarChange(for: .profileAvatarData, with: aDecoder))
self.visibleBadgeIds = Self.decodeVisibleBadgeIds(for: .visibleBadgeIds, with: aDecoder)
if
aDecoder.containsValue(forKey: NSCodingKeys.userProfileWriter.rawValue),
let userProfileWriter = UserProfileWriter(rawValue: UInt(aDecoder.decodeInt32(forKey: NSCodingKeys.userProfileWriter.rawValue)))
{
self.userProfileWriter = userProfileWriter
} else {
self.userProfileWriter = .unknown
}
}
}
// MARK: - Avatar Downloads
extension OWSProfileManager {
fileprivate struct AvatarDownloadKey: Hashable {
let avatarUrlPath: String
let profileKey: Data
}
public func downloadAndDecryptLocalUserAvatarIfNeeded(authedAccount: AuthedAccount) async throws {
let oldProfile = SSKEnvironment.shared.databaseStorageRef.read { tx in OWSUserProfile.getUserProfile(for: .localUser, tx: tx) }
guard
let oldProfile,
let profileKey = oldProfile.profileKey,
let avatarUrlPath = oldProfile.avatarUrlPath,
!avatarUrlPath.isEmpty,
oldProfile.avatarFileName == nil
else {
return
}
let shouldRetry: Bool
do {
let typedProfileKey = ProfileKey(profileKey)
let temporaryFileUrl = try await downloadAndDecryptAvatar(avatarUrlPath: avatarUrlPath, profileKey: typedProfileKey)
var didConsumeFilePath = false
defer {
if !didConsumeFilePath { try? FileManager.default.removeItem(at: temporaryFileUrl) }
}
(shouldRetry, didConsumeFilePath) = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
let newProfile = OWSUserProfile.getUserProfile(for: .localUser, tx: tx)
guard let newProfile, newProfile.avatarFileName == nil else {
return (false, false)
}
// If the URL/profileKey change, we have a new avatar to download.
guard newProfile.profileKey == profileKey, newProfile.avatarUrlPath == avatarUrlPath else {
return (true, false)
}
let avatarFilename = try OWSUserProfile.consumeTemporaryAvatarFileUrl(.setTo(temporaryFileUrl), tx: tx)
newProfile.update(
avatarFileName: avatarFilename,
userProfileWriter: .avatarDownload,
transaction: tx,
)
return (false, true)
}
}
if shouldRetry {
try await downloadAndDecryptLocalUserAvatarIfNeeded(authedAccount: authedAccount)
}
}
public func downloadAndDecryptAvatar(avatarUrlPath: String, profileKey: ProfileKey) async throws -> URL {
let backgroundTask = OWSBackgroundTask(label: "\(#function)")
defer { backgroundTask.end() }
assert(!avatarUrlPath.isEmpty)
return try await Retry.performWithBackoff(maxAttempts: 4, isRetryable: { $0.isNetworkFailureOrTimeout }) {
Logger.info("")
let urlSession = await SSKEnvironment.shared.signalServiceRef.sharedUrlSessionForCdn(cdnNumber: 0, maxResponseSize: nil)
let response = try await urlSession.performDownload(avatarUrlPath, method: .get)
let decryptedFileUrl = OWSFileSystem.temporaryFileUrl(
fileExtension: nil,
isAvailableWhileDeviceLocked: true,
)
try Self.decryptAvatar(at: response.downloadUrl, to: decryptedFileUrl, profileKey: profileKey)
guard (try? DataImageSource.forPath(decryptedFileUrl.path))?.ows_isValidImage ?? false else {
throw OWSGenericError("Couldn't validate avatar")
}
guard UIImage(contentsOfFile: decryptedFileUrl.path) != nil else {
throw OWSGenericError("Couldn't decode image")
}
return decryptedFileUrl
}
}
private static func decryptAvatar(
at encryptedFileUrl: URL,
to decryptedFileUrl: URL,
profileKey: ProfileKey,
) throws {
let readHandle = try FileHandle(forReadingFrom: encryptedFileUrl)
defer {
try? readHandle.close()
}
guard
FileManager.default.createFile(
atPath: decryptedFileUrl.path,
contents: nil,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
)
else {
throw OWSGenericError("Couldn't create temporary file")
}
let writeHandle = try FileHandle(forWritingTo: decryptedFileUrl)
defer {
try? writeHandle.close()
}
let concatenatedLength = Int(try readHandle.seekToEnd())
try readHandle.seek(toOffset: 0)
let nonceLength = Aes256GcmEncryptedData.nonceLength
let authenticationTagLength = Aes256GcmEncryptedData.authenticationTagLength
var remainingLength = concatenatedLength - nonceLength - authenticationTagLength
guard remainingLength >= 0 else {
throw OWSGenericError("ciphertext too short")
}
let nonceData = try readHandle.read(upToCount: nonceLength) ?? Data()
let decryptor = try Aes256GcmDecryption(key: profileKey.serialize(), nonce: nonceData, associatedData: [])
while remainingLength > 0 {
let kBatchLimit = 32768
var payloadData: Data = try readHandle.read(upToCount: min(remainingLength, kBatchLimit)) ?? Data()
try decryptor.decrypt(&payloadData)
try writeHandle.write(contentsOf: payloadData)
remainingLength -= payloadData.count
}
let authenticationTag = try readHandle.read(upToCount: authenticationTagLength) ?? Data()
guard try decryptor.verifyTag(authenticationTag) else {
throw OWSGenericError("failed to decrypt")
}
}
}