Signal-iOS/SignalMessaging/profiles/OWSProfileManager.swift
2021-03-25 09:24:27 -03:00

700 lines
30 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
import PromiseKit
import SignalServiceKit
public extension OWSProfileManager {
// The main entry point for updating the local profile. It will:
//
// * Update local state optimistically.
// * 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.
class func updateLocalProfilePromise(profileGivenName: String?,
profileFamilyName: String?,
profileBio: String?,
profileBioEmoji: String?,
profileAvatarData: Data?,
unsavedRotatedProfileKey: OWSAES256Key? = nil) -> Promise<Void> {
assert(CurrentAppContext().isMainApp)
return DispatchQueue.global().async(.promise) {
return enqueueProfileUpdate(profileGivenName: profileGivenName,
profileFamilyName: profileFamilyName,
profileBio: profileBio,
profileBioEmoji: profileBioEmoji,
profileAvatarData: profileAvatarData,
unsavedRotatedProfileKey: unsavedRotatedProfileKey)
}.then { update in
return self.attemptToUpdateProfileOnService(update: update)
}.then { (_) throws -> Promise<Void> in
guard unsavedRotatedProfileKey == nil else {
Logger.info("Skipping local profile fetch during key rotation.")
return Promise.value(())
}
guard let localAddress = TSAccountManager.shared.localAddress else {
throw OWSAssertionError("missing local address")
}
return ProfileFetcherJob.fetchProfilePromise(address: localAddress, mainAppOnly: false, ignoreThrottling: true, fetchType: .default).asVoid()
}.done(on: .global()) { () -> Void in
Logger.verbose("Profile update did complete.")
}
}
// This will re-upload the existing local profile state.
func reuploadLocalProfilePromise(unsavedRotatedProfileKey: OWSAES256Key? = nil) -> Promise<Void> {
Logger.info("")
let profileGivenName: String?
let profileFamilyName: String?
let profileBio: String?
let profileBioEmoji: String?
let profileAvatarData: Data?
if let pendingUpdate = (Self.databaseStorage.read { transaction in
return Self.currentPendingProfileUpdate(transaction: transaction)
}) {
profileGivenName = pendingUpdate.profileGivenName
profileFamilyName = pendingUpdate.profileFamilyName
profileBio = pendingUpdate.profileBio
profileBioEmoji = pendingUpdate.profileBioEmoji
profileAvatarData = pendingUpdate.profileAvatarData
} else {
let profileSnapshot = localProfileSnapshot(shouldIncludeAvatar: true)
profileGivenName = profileSnapshot.givenName
profileFamilyName = profileSnapshot.familyName
profileBio = profileSnapshot.bio
profileBioEmoji = profileSnapshot.bioEmoji
profileAvatarData = profileSnapshot.avatarData
}
assert(profileGivenName != nil)
return OWSProfileManager.updateLocalProfilePromise(profileGivenName: profileGivenName,
profileFamilyName: profileFamilyName,
profileBio: profileBio,
profileBioEmoji: profileBioEmoji,
profileAvatarData: profileAvatarData,
unsavedRotatedProfileKey: unsavedRotatedProfileKey)
}
@objc
func allWhitelistedRegisteredAddresses(transaction: SDSAnyReadTransaction) -> [SignalServiceAddress] {
var addresses = Set<SignalServiceAddress>()
for uuid in whitelistedUUIDsStore.allKeys(transaction: transaction) {
addresses.insert(SignalServiceAddress(uuidString: uuid))
}
for phoneNumber in whitelistedPhoneNumbersStore.allKeys(transaction: transaction) {
addresses.insert(SignalServiceAddress(phoneNumber: phoneNumber))
}
return AnySignalRecipientFinder().signalRecipients(for: Array(addresses), transaction: transaction)
.filter { $0.devices.count > 0 }
.map { $0.address }
}
@objc
@available(swift, obsoleted: 1.0)
func rotateProfileKey(
intersectingPhoneNumbers: Set<String>,
intersectingUUIDs: Set<String>,
intersectingGroupIds: Set<Data>
) -> AnyPromise {
return AnyPromise(rotateProfileKey(
intersectingPhoneNumbers: intersectingPhoneNumbers,
intersectingUUIDs: intersectingUUIDs,
intersectingGroupIds: intersectingGroupIds
))
}
func rotateProfileKey(
intersectingPhoneNumbers: Set<String>,
intersectingUUIDs: Set<String>,
intersectingGroupIds: Set<Data>
) -> Promise<Void> {
guard tsAccountManager.isRegisteredPrimaryDevice else {
return Promise(error: OWSAssertionError("tsAccountManager.isRegistered was unexpectely false"))
}
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.
return firstly(on: .global()) { () -> Promise<OWSAES256Key> in
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 = OWSAES256Key.generateRandom()
return self.reuploadLocalProfilePromise(unsavedRotatedProfileKey: newProfileKey).map { newProfileKey }
}.then(on: .global()) { newProfileKey -> Promise<Void> in
guard let localAddress = self.tsAccountManager.localAddress else {
throw OWSAssertionError("Missing local address")
}
Logger.info("Persisting rotated profile key and kicking off subsequent operations.")
return self.databaseStorage.write(.promise) { transaction in
self.setLocalProfileKey(newProfileKey, wasLocallyInitiated: true, transaction: transaction)
// Whenever a user's profile key changes, we need to fetch a new
// profile key credential for them.
self.versionedProfiles.clearProfileKeyCredential(for: localAddress, transaction: transaction)
// We schedule the updates here but process them below using processProfileKeyUpdates.
// It's more efficient to process them after the intermediary steps are done.
self.groupsV2.scheduleAllGroupsV2ForProfileKeyUpdate(transaction: transaction)
// 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).
self.whitelistedPhoneNumbersStore.removeValues(
forKeys: Array(intersectingPhoneNumbers),
transaction: transaction
)
self.whitelistedUUIDsStore.removeValues(
forKeys: Array(intersectingUUIDs),
transaction: transaction
)
self.whitelistedGroupsStore.removeValues(
forKeys: intersectingGroupIds.map { self.groupKey(forGroupId: $0) },
transaction: transaction
)
}
}.then(on: .global()) { () -> Promise<Void> in
Logger.info("Updating account attributes after profile key rotation.")
return self.tsAccountManager.updateAccountAttributes()
}.done(on: .global()) {
Logger.info("Completed profile key rotation.")
self.groupsV2.processProfileKeyUpdates()
}
}
}
// MARK: -
@objc
public extension OWSProfileManager {
@available(swift, obsoleted: 1.0)
class func updateProfileOnServiceIfNecessary() {
updateProfileOnServiceIfNecessary()
}
// This will re-upload the existing local profile state.
@available(swift, obsoleted: 1.0)
func reuploadLocalProfilePromise() -> AnyPromise {
return AnyPromise(reuploadLocalProfilePromise())
}
@available(swift, obsoleted: 1.0)
class func updateLocalProfilePromise(profileGivenName: String?,
profileFamilyName: String?,
profileBio: String?,
profileBioEmoji: String?,
profileAvatarData: Data?) -> AnyPromise {
return AnyPromise(updateLocalProfilePromise(
profileGivenName: profileGivenName,
profileFamilyName: profileFamilyName,
profileBio: profileBio,
profileBioEmoji: profileBioEmoji,
profileAvatarData: profileAvatarData
))
}
}
// MARK: -
extension OWSProfileManager {
private class var avatarUrlSession: OWSURLSession {
return OWSSignalService.shared().urlSessionForCdn(cdnNumber: 0)
}
@objc
static let settingsStore = SDSKeyValueStore(collection: "kOWSProfileManager_SettingsStore")
// MARK: -
public class func updateProfileOnServiceIfNecessary(retryDelay: TimeInterval = 1) {
AssertIsOnMainThread()
guard AppReadiness.isAppReady else {
return
}
guard tsAccountManager.isRegisteredAndReady else {
return
}
guard !profileManagerImpl.isUpdatingProfileOnService else {
// Avoid having two redundant updates in flight at the same time.
return
}
let pendingUpdate = self.databaseStorage.read { transaction in
return self.currentPendingProfileUpdate(transaction: transaction)
}
guard let update = pendingUpdate else {
return
}
firstly {
attemptToUpdateProfileOnService(update: update,
retryDelay: retryDelay)
}.done { _ in
Logger.info("Update succeeded.")
}.catch { error in
Logger.error("Update failed: \(error)")
}
}
fileprivate class func attemptToUpdateProfileOnService(update: PendingProfileUpdate,
retryDelay: TimeInterval = 1) -> Promise<Void> {
AssertIsOnMainThread()
Logger.verbose("")
if !update.hasGivenName {
Logger.info("Setting empty given name.")
}
if !update.hasAvatarData {
Logger.info("Setting empty avatar.")
}
profileManagerImpl.isUpdatingProfileOnService = true
// We capture the local user profile early to eliminate the
// risk of opening a transaction within a transaction.
let userProfile = profileManagerImpl.localUserProfile()
let attempt = ProfileUpdateAttempt(update: update,
userProfile: userProfile)
let promise = firstly {
writeProfileAvatarToDisk(attempt: attempt)
}.then(on: DispatchQueue.global()) { () -> Promise<Void> in
// Optimistically update local profile state.
databaseStorage.write { transaction in
self.updateLocalProfile(with: attempt, transaction: transaction)
}
Logger.info("Versioned profile update.")
return updateProfileOnServiceVersioned(attempt: attempt)
}.done(on: DispatchQueue.global()) { _ in
self.databaseStorage.write { (transaction: SDSAnyWriteTransaction) -> Void in
guard tryToDequeueProfileUpdate(update: attempt.update, transaction: transaction) else {
return
}
if attempt.update.profileAvatarData != nil {
if attempt.avatarFilename == nil {
owsFailDebug("Missing avatarFilename.")
}
if attempt.avatarUrlPath == nil {
owsFailDebug("Missing avatarUrlPath.")
}
}
self.updateLocalProfile(with: attempt, transaction: transaction)
}
self.attemptDidComplete(retryDelay: retryDelay, didSucceed: true)
}.recover(on: .global()) { error in
// We retry network errors forever (with exponential backoff).
// Other errors cause us to give up immediately.
// Note that we only ever retry the latest profile update.
if IsNetworkConnectivityFailure(error) {
Logger.warn("Retrying after error: \(error)")
} else {
owsFailDebug("Error: \(error)")
// Dequeue to avoid getting stuck in retry loop.
self.databaseStorage.write { transaction in
_ = tryToDequeueProfileUpdate(update: attempt.update, transaction: transaction)
}
}
self.attemptDidComplete(retryDelay: retryDelay, didSucceed: false)
// We don't actually want to recover; in this block we
// handle the business logic consequences of the error,
// but we re-throw so that the UI can distinguish
// success and failure.
throw error
}
return promise
}
private class func attemptDidComplete(retryDelay: TimeInterval, didSucceed: Bool) {
// We use a "self-only" contact sync to indicate to desktop
// that we've changed our profile and that it should do a
// profile fetch for "self".
//
// NOTE: We also inform the desktop in the failure case,
// since that _may have_ affected service state.
if self.tsAccountManager.isRegisteredPrimaryDevice {
firstly {
self.syncManager.syncLocalContact()
}.catch { error in
Logger.warn("Error: \(error)")
}
}
// Notify all our devices that the profile has changed.
// Older linked devices may not handle this message.
self.syncManager.sendFetchLatestProfileSyncMessage()
DispatchQueue.main.async {
// Clear this flag immediately.
self.profileManagerImpl.isUpdatingProfileOnService = false
// There may be another update enqueued that we should kick off.
// Or we may need to retry.
if didSucceed {
self.updateProfileOnServiceIfNecessary()
} else {
// We don't want to get in a retry loop, so we use exponential backoff
// in the failure case.
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
self.updateProfileOnServiceIfNecessary(retryDelay: (retryDelay + 1) * 2)
})
}
}
}
private class func writeProfileAvatarToDisk(attempt: ProfileUpdateAttempt) -> Promise<Void> {
guard let profileAvatarData = attempt.update.profileAvatarData else {
return Promise.value(())
}
let (promise, resolver) = Promise<Void>.pending()
DispatchQueue.global().async {
self.profileManagerImpl.writeAvatarToDisk(with: profileAvatarData,
success: { avatarFilename in
attempt.avatarFilename = avatarFilename
resolver.fulfill(())
}, failure: { (error) in
resolver.reject(error)
})
}
return promise
}
private class func updateProfileOnServiceVersioned(attempt: ProfileUpdateAttempt) -> Promise<Void> {
Logger.info("avatar?: \(attempt.update.profileAvatarData != nil), " +
"profileGivenName?: \(attempt.update.profileGivenName != nil), " +
"profileFamilyName?: \(attempt.update.profileFamilyName != nil), " +
"profileBio?: \(attempt.update.profileBio != nil), " +
"profileBioEmoji?: \(attempt.update.profileBioEmoji != nil).")
return firstly(on: .global()) {
Self.versionedProfilesImpl.updateProfilePromise(profileGivenName: attempt.update.profileGivenName,
profileFamilyName: attempt.update.profileFamilyName,
profileBio: attempt.update.profileBio,
profileBioEmoji: attempt.update.profileBioEmoji,
profileAvatarData: attempt.update.profileAvatarData,
unsavedRotatedProfileKey: attempt.update.unsavedRotatedProfileKey)
}.map(on: .global()) { versionedUpdate in
attempt.avatarUrlPath = versionedUpdate.avatarUrlPath
}
}
private class func updateLocalProfile(with attempt: ProfileUpdateAttempt,
transaction: SDSAnyWriteTransaction) {
Logger.verbose("profile givenName: \(String(describing: attempt.update.profileGivenName)), familyName: \(String(describing: attempt.update.profileFamilyName)), avatarFilename: \(String(describing: attempt.avatarFilename))")
attempt.userProfile.updateWith(givenName: attempt.update.profileGivenName,
familyName: attempt.update.profileFamilyName,
avatarUrlPath: attempt.avatarUrlPath,
avatarFileName: attempt.avatarFilename,
transaction: transaction,
completion: nil)
}
// MARK: - Update Queue
private static let kPendingProfileUpdateKey = "kPendingProfileUpdateKey"
private class func enqueueProfileUpdate(profileGivenName: String?,
profileFamilyName: String?,
profileBio: String?,
profileBioEmoji: String?,
profileAvatarData: Data?,
unsavedRotatedProfileKey: OWSAES256Key?) -> PendingProfileUpdate {
Logger.verbose("")
// Note that this might overwrite a pending profile update.
// That's desirable. We only ever want to retain the
// latest changes.
let update = PendingProfileUpdate(profileGivenName: profileGivenName,
profileFamilyName: profileFamilyName,
profileBio: profileBio,
profileBioEmoji: profileBioEmoji,
profileAvatarData: profileAvatarData,
unsavedRotatedProfileKey: unsavedRotatedProfileKey)
databaseStorage.write { transaction in
self.settingsStore.setObject(update, key: kPendingProfileUpdateKey, transaction: transaction)
}
return update
}
private class func currentPendingProfileUpdate(transaction: SDSAnyReadTransaction) -> PendingProfileUpdate? {
guard let value = settingsStore.getObject(forKey: kPendingProfileUpdateKey, transaction: transaction) else {
return nil
}
guard let update = value as? PendingProfileUpdate else {
owsFailDebug("Invalid value.")
return nil
}
return update
}
private class func isCurrentPendingProfileUpdate(update: PendingProfileUpdate, transaction: SDSAnyReadTransaction) -> Bool {
guard let currentUpdate = currentPendingProfileUpdate(transaction: transaction) else {
return false
}
return update.hasSameIdAs(currentUpdate)
}
private class func tryToDequeueProfileUpdate(update: PendingProfileUpdate, transaction: SDSAnyWriteTransaction) -> Bool {
Logger.verbose("")
guard self.isCurrentPendingProfileUpdate(update: update, transaction: transaction) else {
Logger.warn("Ignoring stale update completion.")
return false
}
self.settingsStore.removeValue(forKey: kPendingProfileUpdateKey, transaction: transaction)
return true
}
}
// MARK: -
class PendingProfileUpdate: NSObject, NSCoding {
let id: UUID
// If nil, we are clearing the profile given name.
let profileGivenName: String?
// If nil, we are clearing the profile family name.
let profileFamilyName: String?
let profileBio: String?
let profileBioEmoji: String?
// If nil, we are clearing the profile avatar.
let profileAvatarData: Data?
let unsavedRotatedProfileKey: OWSAES256Key?
var hasGivenName: Bool {
guard let givenName = profileGivenName else {
return false
}
return !givenName.isEmpty
}
var hasAvatarData: Bool {
guard let avatarData = profileAvatarData else {
return false
}
return !avatarData.isEmpty
}
init(profileGivenName: String?,
profileFamilyName: String?,
profileBio: String?,
profileBioEmoji: String?,
profileAvatarData: Data?,
unsavedRotatedProfileKey: OWSAES256Key?) {
self.id = UUID()
self.profileGivenName = profileGivenName
self.profileFamilyName = profileFamilyName
self.profileBio = profileBio
self.profileBioEmoji = profileBioEmoji
self.profileAvatarData = profileAvatarData
self.unsavedRotatedProfileKey = unsavedRotatedProfileKey
}
func hasSameIdAs(_ other: PendingProfileUpdate) -> Bool {
return self.id == other.id
}
// MARK: - NSCoding
@objc
public func encode(with aCoder: NSCoder) {
aCoder.encode(id.uuidString, forKey: "id")
aCoder.encode(profileGivenName, forKey: "profileGivenName")
aCoder.encode(profileFamilyName, forKey: "profileFamilyName")
aCoder.encode(profileBio, forKey: "profileBio")
aCoder.encode(profileBioEmoji, forKey: "profileBioEmoji")
aCoder.encode(profileAvatarData, forKey: "profileAvatarData")
aCoder.encode(unsavedRotatedProfileKey, forKey: "unsavedRotatedProfileKey")
}
@objc
public required init?(coder aDecoder: NSCoder) {
guard let idString = aDecoder.decodeObject(forKey: "id") as? String,
let id = UUID(uuidString: idString) else {
owsFailDebug("Missing id")
return nil
}
self.id = id
self.profileGivenName = aDecoder.decodeObject(forKey: "profileGivenName") as? String
self.profileFamilyName = aDecoder.decodeObject(forKey: "profileFamilyName") as? String
self.profileBio = aDecoder.decodeObject(forKey: "profileBio") as? String
self.profileBioEmoji = aDecoder.decodeObject(forKey: "profileBioEmoji") as? String
self.profileAvatarData = aDecoder.decodeObject(forKey: "profileAvatarData") as? Data
self.unsavedRotatedProfileKey = aDecoder.decodeObject(forKey: "unsavedRotatedProfileKey") as? OWSAES256Key
}
}
// MARK: -
private class ProfileUpdateAttempt {
let update: PendingProfileUpdate
let userProfile: OWSUserProfile
// These properties are populated during the update process.
var avatarFilename: String?
var avatarUrlPath: String?
init(update: PendingProfileUpdate, userProfile: OWSUserProfile) {
self.update = update
self.userProfile = userProfile
}
}
// MARK: - Avatar Downloads
public extension OWSProfileManager {
private struct CacheKey: Hashable {
let avatarUrlPath: String
let profileKey: Data
}
private static let serialQueue = DispatchQueue(label: "ProfileManager.serialQueue")
private static var avatarDownloadCache = [CacheKey: Promise<Data>]()
@objc
class func avatarDownloadAndDecryptPromiseObjc(profileAddress: SignalServiceAddress,
avatarUrlPath: String,
profileKey: OWSAES256Key) -> AnyPromise {
return AnyPromise(avatarDownloadAndDecryptPromise(profileAddress: profileAddress,
avatarUrlPath: avatarUrlPath,
profileKey: profileKey))
}
private class func avatarDownloadAndDecryptPromise(profileAddress: SignalServiceAddress,
avatarUrlPath: String,
profileKey: OWSAES256Key) -> Promise<Data> {
let cacheKey = CacheKey(avatarUrlPath: avatarUrlPath, profileKey: profileKey.keyData)
return serialQueue.sync { () -> Promise<Data> in
if let cachedPromise = avatarDownloadCache[cacheKey] {
return cachedPromise
}
let promise = avatarDownloadAndDecryptPromise(profileAddress: profileAddress,
avatarUrlPath: avatarUrlPath,
profileKey: profileKey,
remainingRetries: 3)
avatarDownloadCache[cacheKey] = promise
_ = promise.ensure(on: .global()) {
serialQueue.sync {
guard avatarDownloadCache[cacheKey] != nil else {
owsFailDebug("Missing cached promise.")
return
}
avatarDownloadCache.removeValue(forKey: cacheKey)
}
}
return promise
}
}
private class func avatarDownloadAndDecryptPromise(profileAddress: SignalServiceAddress,
avatarUrlPath: String,
profileKey: OWSAES256Key,
remainingRetries: UInt) -> Promise<Data> {
assert(!avatarUrlPath.isEmpty)
var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "\(#function)")
return firstly(on: .global()) { () throws -> Promise<OWSUrlDownloadResponse> in
Logger.verbose("downloading profile avatar: \(profileAddress)")
let urlSession = self.avatarUrlSession
return urlSession.urlDownloadTaskPromise(avatarUrlPath,
method: .get,
progress: { (_, progress) in
Logger.verbose("Downloading avatar for \(profileAddress) \(progress.fractionCompleted)")
})
}.map(on: .global()) { (response: OWSUrlDownloadResponse) -> Data in
do {
return try Data(contentsOf: response.downloadUrl)
} catch {
owsFailDebug("Could not load data failed: \(error)")
// Fail immediately; do not retry.
throw error.asUnretryableError
}
}.recover(on: .global()) { (error: Error) -> Promise<Data> in
throw error.withDefaultRetry
}.map(on: .global()) { (encryptedData: Data) -> Data in
guard let decryptedData = OWSUserProfile.decrypt(profileData: encryptedData,
profileKey: profileKey) else {
throw OWSGenericError("Could not decrypt profile avatar.").asUnretryableError
}
return decryptedData
}.recover { error -> Promise<Data> in
if error.isNetworkFailureOrTimeout,
remainingRetries > 0 {
// Retry
return self.avatarDownloadAndDecryptPromise(profileAddress: profileAddress,
avatarUrlPath: avatarUrlPath,
profileKey: profileKey,
remainingRetries: remainingRetries - 1)
} else {
throw error
}
}.ensure {
assert(backgroundTask != nil)
backgroundTask = nil
}
}
}
// MARK: -
extension OWSUserProfile {
#if TESTABLE_BUILD
func logDates(prefix: String) {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
var lastFetchDateString = "nil"
if let lastFetchDate = lastFetchDate {
lastFetchDateString = formatter.string(from: lastFetchDate)
}
var lastMessagingDateString = "nil"
if let lastMessagingDate = lastMessagingDate {
lastMessagingDateString = formatter.string(from: lastMessagingDate)
}
Logger.verbose("\(prefix): \(address), lastFetchDate: \(lastFetchDateString), lastMessagingDate: \(lastMessagingDateString).")
}
#endif
}