import Foundation import Network import ScoreTrackerKit import UIKit /// Everything `bindToAppLifecycle()` installed, so `unbindAppLifecycle()` can /// remove exactly that (#484). final class AppBindings { let controller: AppLifecycleController var observers: [NSObjectProtocol] = [] var pathMonitor: NWPathMonitor? var networkWasSatisfied = true init(controller: AppLifecycleController) { self.controller = controller } } extension ScoreClient { /// Flush on the app's lifecycle and connectivity, so you don't wire them by /// hand (#484). One call, opt-in — typically in /// `application(_:didFinishLaunchingWithOptions:)`: /// /// score.bindToAppLifecycle() /// /// - Foreground (`willEnterForeground`, or already active when you bind): /// flush, and flush every `autoFlushEverySeconds` (nil: leave auto-flush /// to you). /// - Background (`didEnterBackground`): stop the periodic flush, then a /// best-effort flush bounded by `backgroundBudgetSeconds`, inside a /// background task that ends as soon as the flush does. /// - Network back (`NWPathMonitor` turns satisfied): flush. /// /// The behaviour is the shared `AppLifecycleController`, the same one the /// Android binding drives. Call on the main thread. If you use /// `startAutoFlush(everySeconds:)` yourself, binding with an interval /// replaces it; pass `autoFlushEverySeconds: nil` to keep yours. @available(iOSApplicationExtension, unavailable, message: "App lifecycle notifications aren't delivered to extensions; flush manually.") public func bindToAppLifecycle(autoFlushEverySeconds: Double? = 30, backgroundBudgetSeconds: Double = 5) { dispatchPrecondition(condition: .onQueue(.main)) unbindAppLifecycle() if autoFlushEverySeconds != nil { stopAutoFlush() } // one periodic flush, not two let controller = IosPlatformKt.doNewAppLifecycleController( tracker: tracker, autoFlushIntervalMs: Int64((autoFlushEverySeconds ?? 0) * 1000), // 0 → none backgroundBudgetMs: Int64(backgroundBudgetSeconds * 1000) ) let bindings = AppBindings(controller: controller) let center = NotificationCenter.default bindings.observers.append(center.addObserver( forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main ) { _ in controller.appDidEnterForeground() }) bindings.observers.append(center.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { _ in // Ask iOS for time to finish the flush; hand it back the moment the // bounded flush is done (or, if iOS's own deadline comes first, then). var task = UIBackgroundTaskIdentifier.invalid let end = { guard task != .invalid else { return } UIApplication.shared.endBackgroundTask(task) task = .invalid } task = UIApplication.shared.beginBackgroundTask(withName: "ScoreTracker.flush", expirationHandler: end) controller.appDidEnterBackground(onDone: end) }) // Android parity: binding while already in the foreground counts as one. if UIApplication.shared.applicationState == .active { controller.appDidEnterForeground() } // Flush on the transition to "satisfied", not on every path update. let monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak bindings] path in DispatchQueue.main.async { guard let bindings else { return } let satisfied = path.status == .satisfied if satisfied && !bindings.networkWasSatisfied { bindings.controller.networkDidBecomeAvailable() } bindings.networkWasSatisfied = satisfied } } monitor.start(queue: DispatchQueue(label: "com.threehalveslabs.scoretracker.network")) bindings.pathMonitor = monitor appBindings = bindings } /// Remove everything `bindToAppLifecycle()` installed. Safe to call when not bound. public func unbindAppLifecycle() { dispatchPrecondition(condition: .onQueue(.main)) unbindAppLifecycleFromAnyContext() } func unbindAppLifecycleFromAnyContext() { guard let bindings = appBindings else { return } bindings.observers.forEach { NotificationCenter.default.removeObserver($0) } bindings.pathMonitor?.cancel() bindings.controller.close() appBindings = nil } }