diff --git a/yobble/AppDelegate.swift b/yobble/AppDelegate.swift index 7945c81..abba59b 100644 --- a/yobble/AppDelegate.swift +++ b/yobble/AppDelegate.swift @@ -6,6 +6,7 @@ import UserNotifications import UIKit class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { + private let pushTokenManager = PushTokenManager.shared func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { @@ -52,6 +53,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele } func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("🔥 FCM token:", fcmToken ?? "NO TOKEN") + guard let fcmToken else { + if AppConfig.DEBUG { print("🔥 FCM token: NO TOKEN") } + return + } + + if AppConfig.DEBUG { print("🔥 FCM token:", fcmToken) } + pushTokenManager.registerFCMToken(fcmToken) } } diff --git a/yobble/Network/SessionsService.swift b/yobble/Network/SessionsService.swift index acd40b0..35f42b8 100644 --- a/yobble/Network/SessionsService.swift +++ b/yobble/Network/SessionsService.swift @@ -171,6 +171,50 @@ final class SessionsService { } } + func updatePushToken(_ token: String, completion: @escaping (Result) -> Void) { + client.request( + path: "/v1/auth/sessions/update_push_token", + method: .post, + query: ["fcm_token": token], + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить push-токен.", comment: "Sessions service update push unexpected status") + completion(.failure(SessionsServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data.message)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[SessionsService] decode update-push failed: \(debugMessage)") + } + completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(SessionsServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func updatePushToken(_ token: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + updatePushToken(token) { result in + continuation.resume(with: result) + } + } + } + private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 701f282..6311b08 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1198,6 +1198,9 @@ "Не удалось найти результаты." : { "comment" : "Search unexpected status" }, + "Не удалось обновить push-токен." : { + "comment" : "Sessions service update push unexpected status" + }, "Не удалось обновить пароль." : { "localizations" : { "en" : { diff --git a/yobble/Services/PushTokenManager.swift b/yobble/Services/PushTokenManager.swift new file mode 100644 index 0000000..3cc69f4 --- /dev/null +++ b/yobble/Services/PushTokenManager.swift @@ -0,0 +1,155 @@ +import Foundation +import UIKit + +final class PushTokenManager { + static let shared = PushTokenManager() + + private let queue = DispatchQueue(label: "org.yobble.push-token", qos: .utility) + private let sessionsService: SessionsService + private var currentFCMToken: String? + private var lastSentTokens: [String: String] + private var loginsRequiringSync: Set = [] + private var isUpdating = false + private var pendingUpdate = false + private var retryWorkItem: DispatchWorkItem? + private var notificationTokens: [NSObjectProtocol] = [] + + private enum Keys { + static let storedToken = "push.current_fcm_token" + static let sentTokens = "push.last_sent_tokens" + static let currentUser = "currentUser" + } + + private enum Constants { + static let retryDelay: TimeInterval = 20 + } + + private init(sessionsService: SessionsService = SessionsService()) { + self.sessionsService = sessionsService + let defaults = UserDefaults.standard + self.currentFCMToken = defaults.string(forKey: Keys.storedToken) + self.lastSentTokens = defaults.dictionary(forKey: Keys.sentTokens) as? [String: String] ?? [:] + observeNotifications() + + queue.async { [weak self] in + guard let self else { return } + if let login = self.currentLogin() { + self.loginsRequiringSync.insert(login) + } + self.pendingUpdate = true + self.tryUpdateTokenIfNeeded() + } + } + + deinit { + notificationTokens.forEach { NotificationCenter.default.removeObserver($0) } + notificationTokens.removeAll() + } + + func registerFCMToken(_ token: String) { + queue.async { [weak self] in + guard let self else { return } + guard self.currentFCMToken != token else { return } + + self.currentFCMToken = token + UserDefaults.standard.set(token, forKey: Keys.storedToken) + + if let login = self.currentLogin() { + self.loginsRequiringSync.insert(login) + } + + self.pendingUpdate = true + self.tryUpdateTokenIfNeeded() + } + } + + private func observeNotifications() { + let center = NotificationCenter.default + + let accessTokenObserver = center.addObserver(forName: .accessTokenDidChange, object: nil, queue: nil) { [weak self] _ in + guard let self else { return } + self.queue.async { + if let login = self.currentLogin() { + self.loginsRequiringSync.insert(login) + } else { + self.loginsRequiringSync.removeAll() + } + self.pendingUpdate = true + self.tryUpdateTokenIfNeeded() + } + } + notificationTokens.append(accessTokenObserver) + + let didBecomeActiveObserver = center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in + guard let self else { return } + self.queue.async { + self.pendingUpdate = true + self.tryUpdateTokenIfNeeded() + } + } + notificationTokens.append(didBecomeActiveObserver) + } + + private func tryUpdateTokenIfNeeded() { + guard pendingUpdate else { return } + guard !isUpdating else { return } + guard let login = currentLogin() else { return } + guard let token = currentFCMToken, !token.isEmpty else { return } + + let needsForcedSync = loginsRequiringSync.contains(login) + if !needsForcedSync, let lastToken = lastSentTokens[login], lastToken == token { + pendingUpdate = false + return + } + + pendingUpdate = false + isUpdating = true + retryWorkItem?.cancel() + retryWorkItem = nil + + sessionsService.updatePushToken(token) { [weak self] result in + guard let self else { return } + self.queue.async { + self.isUpdating = false + + switch result { + case .success: + self.loginsRequiringSync.remove(login) + self.lastSentTokens[login] = token + UserDefaults.standard.set(self.lastSentTokens, forKey: Keys.sentTokens) + if AppConfig.DEBUG { + print("[PushTokenManager] Push token updated for @\(login)") + } + case .failure(let error): + if AppConfig.DEBUG { + print("[PushTokenManager] Failed to update push token: \(error.localizedDescription)") + } + self.loginsRequiringSync.insert(login) + self.pendingUpdate = true + self.scheduleRetry() + } + + self.tryUpdateTokenIfNeeded() + } + } + } + + private func scheduleRetry() { + guard retryWorkItem == nil else { return } + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.retryWorkItem = nil + self.pendingUpdate = true + self.tryUpdateTokenIfNeeded() + } + retryWorkItem = workItem + queue.asyncAfter(deadline: .now() + Constants.retryDelay, execute: workItem) + } + + private func currentLogin() -> String? { + guard let login = UserDefaults.standard.string(forKey: Keys.currentUser), !login.isEmpty else { + return nil + } + return login + } +}