| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- // LoopFollow
- // AppDelegate.swift
- import AVFoundation
- import UIKit
- import UserNotifications
- class AppDelegate: UIResponder, UIApplicationDelegate {
- let notificationCenter = UNUserNotificationCenter.current()
- func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
- LogManager.shared.log(category: .general, message: "App started")
- LogManager.shared.cleanupOldLogs()
- // Notification and calendar permissions are no longer requested here.
- // They're deferred to the moment the user opts into the feature that
- // needs them (alarms request notifications via NotificationAuthorization;
- // the Calendar settings screen requests calendar access), so a fresh
- // install isn't fronted with permission prompts before onboarding.
- // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
- // locked launch, so it alone isn't a BFU signal — post-first-unlock
- // UserDefaults (class C) reads fine while locked. Only true BFU makes a key
- // that should exist read as absent. Suspect only when ALL presence probes
- // are absent, so an existing user who updated but hasn't foregrounded this
- // build isn't misread on an ordinary locked launch. Probes cover every user
- // shape (marker / migrated / consented / NS-configured); presence, not value.
- _ = Storage.shared // ensure every StorageValue is registered before recovery
- let storageConfirmedReadable = StorageReadiness.markerExists
- || Storage.shared.migrationStep.exists
- || Storage.shared.telemetryConsentDecisionMade.exists
- || Storage.shared.url.exists
- let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable
- StorageReadiness.configure(suspectBFU: suspectBFU)
- LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)")
- if suspectBFU {
- // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
- // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
- // authoritative; willEnterForeground is a fallback.
- let nc = NotificationCenter.default
- nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
- nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
- // Race guard: protected data may have become available between the check
- // above and the observer registration just now.
- if UIApplication.shared.isProtectedDataAvailable {
- completeStorageRecovery()
- }
- }
- let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
- let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
- UNUserNotificationCenter.current().setNotificationCategories([category])
- UNUserNotificationCenter.current().delegate = self
- // Only spin up Bluetooth if the user has chosen a BLE-based background
- // refresh. Initializing BLEManager creates a CBCentralManager, which
- // triggers the Bluetooth permission prompt — deferring it keeps that
- // prompt off fresh installs until the feature is actually enabled.
- if Storage.shared.backgroundRefreshType.value.isBluetooth {
- _ = BLEManager.shared
- }
- // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
- _ = VolumeButtonHandler.shared
- // Register for remote notifications
- DispatchQueue.main.async {
- UIApplication.shared.registerForRemoteNotifications()
- }
- BackgroundRefreshManager.shared.register()
- // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
- // window — poisoned defaults would discard real history. Runs synchronously
- // on a normal launch.
- StorageReadiness.whenReady {
- // Keep the bundle language aligned with the user's manual selection.
- // Waiting for storage readiness avoids reading default values during
- // a Before-First-Unlock background launch.
- AppLanguage.applyStored()
- // SHA change fires an immediate ping (the scheduler can't notice an app
- // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
- TelemetryClient.shared.recordColdLaunch()
- Task.detached {
- if TelemetryClient.shared.buildShaChangedSinceLastSend() {
- await TelemetryClient.shared.maybeSend()
- }
- TelemetryClient.shared.scheduleRecurring()
- }
- }
- return true
- }
- // MARK: - BFU recovery
- @objc private func protectedDataDidBecomeAvailable() {
- completeStorageRecovery()
- }
- @objc private func handleWillEnterForeground() {
- completeStorageRecovery()
- }
- private func completeStorageRecovery() {
- // recover() hydrates every value and opens the gate; true only on the one
- // transition that did the work, so the notification fires exactly once.
- guard StorageReadiness.recover() else { return }
- LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'")
- NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
- }
- func applicationWillTerminate(_: UIApplication) {
- #if !targetEnvironment(macCatalyst)
- LiveActivityManager.shared.endOnTerminate()
- #endif
- }
- // MARK: - Remote Notifications
- /// Called when successfully registered for remote notifications
- func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
- let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
- Observable.shared.loopFollowDeviceToken.value = tokenString
- LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
- }
- /// Called when failed to register for remote notifications
- func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
- LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)")
- }
- /// Called when a remote notification is received
- func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
- let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
- LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")
- // Check if this is a response notification from Loop or Trio
- if let aps = userInfo["aps"] as? [String: Any] {
- // Handle visible notification (alert, sound, badge)
- if let alert = aps["alert"] as? [String: Any] {
- let title = alert["title"] as? String ?? ""
- let body = alert["body"] as? String ?? ""
- LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)")
- }
- // Handle silent notification (content-available)
- if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
- // This is a silent push, nothing implemented but logging for now
- if let commandStatus = userInfo["command_status"] as? String {
- LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)")
- }
- if let commandType = userInfo["command_type"] as? String {
- LogManager.shared.log(category: .apns, message: "Command type: \(commandType)")
- }
- }
- }
- // Call completion handler
- completionHandler(.newData)
- }
- func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
- UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
- return true
- }
- // MARK: - Scene configuration
- // Under the scene-based lifecycle (which the SwiftUI App lifecycle uses),
- // UIKit delivers Home Screen quick actions and opened URLs to the window
- // scene delegate — application(_:performActionFor:) is never called.
- // Injecting a delegate class here is the supported way to receive those
- // events; SwiftUI still creates and manages the window itself.
- func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
- let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
- if connectingSceneSession.role == .windowApplication {
- configuration.delegateClass = AppSceneDelegate.self
- }
- return configuration
- }
- func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
- if response.actionIdentifier == "OPEN_APP_ACTION" {
- // Dismiss any presented modal/sheet so the user actually sees Home
- UIApplication.shared.topMost?.dismiss(animated: true)
- Observable.shared.selectedTabIndex.value = 0
- }
- if response.actionIdentifier == "snooze" {
- AlarmManager.shared.performSnooze()
- }
- completionHandler()
- }
- func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
- let forcePortrait = Storage.shared.forcePortraitMode.value
- if forcePortrait {
- return .portrait
- } else {
- return .all
- }
- }
- }
- extension Notification.Name {
- /// Posted by AppDelegate after a Before-First-Unlock recovery completes
- /// (StorageReadiness.recover has hydrated every value from the now-decrypted
- /// UserDefaults).
- static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
- }
- /// Window scene delegate installed via configurationForConnecting. SwiftUI owns
- /// the window; this class only handles the events UIKit routes to the scene
- /// delegate instead of the application delegate.
- final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
- private let speechSynthesizer = AVSpeechSynthesizer()
- /// A quick action used to cold-launch the app arrives in the connection
- /// options; windowScene(_:performActionFor:) is not called for that launch.
- func scene(_: UIScene, willConnectTo _: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
- if let shortcutItem = connectionOptions.shortcutItem {
- handleShortcutItem(shortcutItem)
- }
- }
- /// Called when the user taps the "Speak BG" Home Screen quick action while
- /// the app is already running.
- func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
- completionHandler(handleShortcutItem(shortcutItem))
- }
- @discardableResult
- private func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
- guard let bundleIdentifier = Bundle.main.bundleIdentifier,
- shortcutItem.type == bundleIdentifier + ".toggleSpeakBG"
- else {
- return false
- }
- Storage.shared.speakBG.value.toggle()
- let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off"
- speechSynthesizer.speak(AVSpeechUtterance(string: message))
- return true
- }
- /// With a custom scene delegate installed, UIKit delivers opened URLs here
- /// rather than through SwiftUI's onOpenURL, so the Live Activity tap
- /// handling from LoopFollowApp is mirrored. Posting twice is harmless —
- /// the navigation it triggers is idempotent.
- func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
- guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return }
- #if !targetEnvironment(macCatalyst)
- DispatchQueue.main.async {
- NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
- }
- #endif
- }
- }
- extension AppDelegate: UNUserNotificationCenterDelegate {
- func userNotificationCenter(_: UNUserNotificationCenter,
- willPresent notification: UNNotification,
- withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
- {
- let content = notification.request.content
- let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted()
- LogManager.shared.log(
- category: .general,
- message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")"
- )
- // Suppress notifications iOS routes here that we never intended to surface:
- // the Live Activity push-to-start uses interruption-level: passive with empty
- // title/body and must not produce a banner or sound when LF is foregrounded.
- if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) {
- completionHandler([])
- return
- }
- completionHandler([.banner, .sound, .badge])
- }
- }
|