Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Profiles/LocalProfileChecker.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation

final class LocalProfileChecker {
    private let db: any DB
    private let messageProcessor: MessageProcessor
    private let profileManager: any ProfileManager
    private let storageServiceManager: any StorageServiceManager
    private let tsAccountManager: any TSAccountManager
    private let udManager: any OWSUDManager

    init(
        db: any DB,
        messageProcessor: MessageProcessor,
        profileManager: any ProfileManager,
        storageServiceManager: any StorageServiceManager,
        tsAccountManager: any TSAccountManager,
        udManager: any OWSUDManager,
    ) {
        self.db = db
        self.messageProcessor = messageProcessor
        self.profileManager = profileManager
        self.storageServiceManager = storageServiceManager
        self.tsAccountManager = tsAccountManager
        self.udManager = udManager
    }

    struct RemoteProfile {
        var avatarUrlPath: String?
        var decryptedProfile: DecryptedProfile?
    }

    private struct State {
        var isReconciling: Bool = false
        var mostRecentRemoteProfile: RemoteProfile?
        var mostRecentRemoteProfileUpdateCount = 0
        var consecutiveMismatchCount = 0
    }

    private let state = AtomicValue(State(), lock: .init())

    func didFetchLocalProfile(_ remoteProfile: RemoteProfile) {
        state.update {
            $0.mostRecentRemoteProfile = remoteProfile
            $0.mostRecentRemoteProfileUpdateCount += 1
        }
        reconcileProfileIfNeeded()
    }

    private func reconcileProfileIfNeeded() {
        let shouldStart = state.update {
            if $0.isReconciling {
                return false
            }
            if $0.mostRecentRemoteProfile == nil {
                return false
            }
            $0.isReconciling = true
            return true
        }
        guard shouldStart else {
            return
        }
        Task {
            do {
                do {
                    defer {
                        state.update { $0.isReconciling = false }
                    }
                    try await self.reconcileProfile()
                }
                reconcileProfileIfNeeded()
            } catch {
                // Stop if we hit an error; we'll retry the next time we fetch our profile.
            }
        }
    }

    private func waitForSteadyState() async throws -> RemoteProfile {
        while true {
            let updateCountSnapshot = state.get().mostRecentRemoteProfileUpdateCount

            // When changing your own profile name/avatar, your profile and Storage
            // Service can't be updated atomically. As a result, it's possible that we
            // may temporarily see inconsistencies. Given that this class is about
            // eventual consistency, wait a few seconds after fetching our own profile
            // to give linked devices a chance to finish updating Storage Service.
            try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)

            // At this point, we believe the linked device will have queued a sync
            // message (if necessary) and that the latest information is available on
            // Storage Service. Wait for both of those systems to stabilize.
            try await messageProcessor.waitForFetchingAndProcessing()
            try await storageServiceManager.waitForPendingRestores()

            // After waiting, ensure we're still considering the same profile. If we're
            // not, wait again since it's possible that our profile changed again.
            let stableProfile = state.update { mutableState -> RemoteProfile? in
                guard mutableState.mostRecentRemoteProfileUpdateCount == updateCountSnapshot else {
                    return nil
                }
                let result = mutableState.mostRecentRemoteProfile!
                mutableState.mostRecentRemoteProfile = nil
                return result
            }
            if let stableProfile {
                return stableProfile
            }
        }
    }

    private func reconcileProfile() async throws {
        let mostRecentRemoteProfile = try await waitForSteadyState()

        var mustReuploadAvatar = false
        let shouldReuploadProfile = db.read { tx in
            guard let localProfile = profileManager.localUserProfile(tx: tx) else {
                return false
            }
            guard let decryptedProfile = mostRecentRemoteProfile.decryptedProfile else {
                Logger.warn("Will reupload; we don't appear to have a profile")
                return true
            }
            // We check these because they are considered "Storage Service properties"
            // in OWSUserProfile.applyChanges & consider the local state to be the
            // source of truth.
            var mismatchedProperties = [String]()
            if localProfile.avatarUrlPath != mostRecentRemoteProfile.avatarUrlPath {
                mustReuploadAvatar = true
                mismatchedProperties.append("avatarUrlPath")
            }
            if localProfile.givenName != (try? decryptedProfile.nameComponents.get()?.givenName) {
                mismatchedProperties.append("givenName")
            }
            if localProfile.familyName != (try? decryptedProfile.nameComponents.get()?.familyName) {
                mismatchedProperties.append("familyName")
            }
            let localPhoneNumberSharing = udManager.phoneNumberSharingMode(tx: tx).orDefault == .everybody
            if localPhoneNumberSharing != (try? decryptedProfile.phoneNumberSharing.get()) {
                mismatchedProperties.append("phoneNumberSharing")
            }

            if mismatchedProperties.isEmpty {
                return false
            }

            Logger.warn("Will reupload; found mismatched properties: [\(mismatchedProperties.joined(separator: ", "))]")
            return true
        }

        guard shouldReuploadProfile else {
            state.update { $0.consecutiveMismatchCount = 0 }
            return
        }

        let consecutiveMismatchCount = state.update {
            $0.consecutiveMismatchCount += 1
            return $0.consecutiveMismatchCount
        }

        let backoffDelay = (1 << (consecutiveMismatchCount - 1)) - 1
        if backoffDelay > 0 {
            Logger.warn("Waiting for \(backoffDelay) second(s) due to consecutive mismatches.")
            try await Task.sleep(nanoseconds: UInt64(backoffDelay) * NSEC_PER_SEC)
        }

        try await db.awaitableWrite { [profileManager] tx in
            profileManager.reuploadLocalProfile(
                unsavedRotatedProfileKey: nil,
                mustReuploadAvatar: mustReuploadAvatar,
                authedAccount: .implicit(),
                tx: tx,
            )
        }.awaitable()
    }
}