AppDelegate.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. // LoopFollow
  2. // AppDelegate.swift
  3. import AVFoundation
  4. import UIKit
  5. import UserNotifications
  6. class AppDelegate: UIResponder, UIApplicationDelegate {
  7. let notificationCenter = UNUserNotificationCenter.current()
  8. func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
  9. LogManager.shared.log(category: .general, message: "App started")
  10. LogManager.shared.cleanupOldLogs()
  11. // Notification and calendar permissions are no longer requested here.
  12. // They're deferred to the moment the user opts into the feature that
  13. // needs them (alarms request notifications via NotificationAuthorization;
  14. // the Calendar settings screen requests calendar access), so a fresh
  15. // install isn't fronted with permission prompts before onboarding.
  16. // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
  17. // locked launch, so it alone isn't a BFU signal — post-first-unlock
  18. // UserDefaults (class C) reads fine while locked. Only true BFU makes a key
  19. // that should exist read as absent. Suspect only when ALL presence probes
  20. // are absent, so an existing user who updated but hasn't foregrounded this
  21. // build isn't misread on an ordinary locked launch. Probes cover every user
  22. // shape (marker / migrated / consented / NS-configured); presence, not value.
  23. _ = Storage.shared // ensure every StorageValue is registered before recovery
  24. let storageConfirmedReadable = StorageReadiness.markerExists
  25. || Storage.shared.migrationStep.exists
  26. || Storage.shared.telemetryConsentDecisionMade.exists
  27. || Storage.shared.url.exists
  28. let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable
  29. StorageReadiness.configure(suspectBFU: suspectBFU)
  30. LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)")
  31. if suspectBFU {
  32. // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
  33. // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
  34. // authoritative; willEnterForeground is a fallback.
  35. let nc = NotificationCenter.default
  36. nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
  37. nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
  38. // Race guard: protected data may have become available between the check
  39. // above and the observer registration just now.
  40. if UIApplication.shared.isProtectedDataAvailable {
  41. completeStorageRecovery()
  42. }
  43. }
  44. let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
  45. let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
  46. UNUserNotificationCenter.current().setNotificationCategories([category])
  47. UNUserNotificationCenter.current().delegate = self
  48. // Only spin up Bluetooth if the user has chosen a BLE-based background
  49. // refresh. Initializing BLEManager creates a CBCentralManager, which
  50. // triggers the Bluetooth permission prompt — deferring it keeps that
  51. // prompt off fresh installs until the feature is actually enabled.
  52. if Storage.shared.backgroundRefreshType.value.isBluetooth {
  53. _ = BLEManager.shared
  54. }
  55. // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications
  56. _ = VolumeButtonHandler.shared
  57. // Register for remote notifications
  58. DispatchQueue.main.async {
  59. UIApplication.shared.registerForRemoteNotifications()
  60. }
  61. BackgroundRefreshManager.shared.register()
  62. // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
  63. // window — poisoned defaults would discard real history. Runs synchronously
  64. // on a normal launch.
  65. StorageReadiness.whenReady {
  66. // SHA change fires an immediate ping (the scheduler can't notice an app
  67. // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
  68. TelemetryClient.shared.recordColdLaunch()
  69. Task.detached {
  70. if TelemetryClient.shared.buildShaChangedSinceLastSend() {
  71. await TelemetryClient.shared.maybeSend()
  72. }
  73. TelemetryClient.shared.scheduleRecurring()
  74. }
  75. }
  76. return true
  77. }
  78. // MARK: - BFU recovery
  79. @objc private func protectedDataDidBecomeAvailable() {
  80. completeStorageRecovery()
  81. }
  82. @objc private func handleWillEnterForeground() {
  83. completeStorageRecovery()
  84. }
  85. private func completeStorageRecovery() {
  86. // recover() hydrates every value and opens the gate; true only on the one
  87. // transition that did the work, so the notification fires exactly once.
  88. guard StorageReadiness.recover() else { return }
  89. LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'")
  90. NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
  91. }
  92. func applicationWillTerminate(_: UIApplication) {
  93. #if !targetEnvironment(macCatalyst)
  94. LiveActivityManager.shared.endOnTerminate()
  95. #endif
  96. }
  97. // MARK: - Remote Notifications
  98. /// Called when successfully registered for remote notifications
  99. func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  100. let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
  101. Observable.shared.loopFollowDeviceToken.value = tokenString
  102. LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
  103. }
  104. /// Called when failed to register for remote notifications
  105. func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
  106. LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)")
  107. }
  108. /// Called when a remote notification is received
  109. func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
  110. let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
  111. LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")
  112. // Check if this is a response notification from Loop or Trio
  113. if let aps = userInfo["aps"] as? [String: Any] {
  114. // Handle visible notification (alert, sound, badge)
  115. if let alert = aps["alert"] as? [String: Any] {
  116. let title = alert["title"] as? String ?? ""
  117. let body = alert["body"] as? String ?? ""
  118. LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)")
  119. }
  120. // Handle silent notification (content-available)
  121. if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 {
  122. // This is a silent push, nothing implemented but logging for now
  123. if let commandStatus = userInfo["command_status"] as? String {
  124. LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)")
  125. }
  126. if let commandType = userInfo["command_type"] as? String {
  127. LogManager.shared.log(category: .apns, message: "Command type: \(commandType)")
  128. }
  129. }
  130. }
  131. // Call completion handler
  132. completionHandler(.newData)
  133. }
  134. func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
  135. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  136. return true
  137. }
  138. // MARK: - Scene configuration
  139. // Under the scene-based lifecycle (which the SwiftUI App lifecycle uses),
  140. // UIKit delivers Home Screen quick actions and opened URLs to the window
  141. // scene delegate — application(_:performActionFor:) is never called.
  142. // Injecting a delegate class here is the supported way to receive those
  143. // events; SwiftUI still creates and manages the window itself.
  144. func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
  145. let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
  146. if connectingSceneSession.role == .windowApplication {
  147. configuration.delegateClass = AppSceneDelegate.self
  148. }
  149. return configuration
  150. }
  151. func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
  152. if response.actionIdentifier == "OPEN_APP_ACTION" {
  153. // Dismiss any presented modal/sheet so the user actually sees Home
  154. UIApplication.shared.topMost?.dismiss(animated: true)
  155. Observable.shared.selectedTabIndex.value = 0
  156. }
  157. if response.actionIdentifier == "snooze" {
  158. AlarmManager.shared.performSnooze()
  159. }
  160. completionHandler()
  161. }
  162. func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
  163. let forcePortrait = Storage.shared.forcePortraitMode.value
  164. if forcePortrait {
  165. return .portrait
  166. } else {
  167. return .all
  168. }
  169. }
  170. }
  171. extension Notification.Name {
  172. /// Posted by AppDelegate after a Before-First-Unlock recovery completes
  173. /// (StorageReadiness.recover has hydrated every value from the now-decrypted
  174. /// UserDefaults).
  175. static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
  176. }
  177. /// Window scene delegate installed via configurationForConnecting. SwiftUI owns
  178. /// the window; this class only handles the events UIKit routes to the scene
  179. /// delegate instead of the application delegate.
  180. final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
  181. private let speechSynthesizer = AVSpeechSynthesizer()
  182. /// A quick action used to cold-launch the app arrives in the connection
  183. /// options; windowScene(_:performActionFor:) is not called for that launch.
  184. func scene(_: UIScene, willConnectTo _: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
  185. if let shortcutItem = connectionOptions.shortcutItem {
  186. handleShortcutItem(shortcutItem)
  187. }
  188. }
  189. /// Called when the user taps the "Speak BG" Home Screen quick action while
  190. /// the app is already running.
  191. func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
  192. completionHandler(handleShortcutItem(shortcutItem))
  193. }
  194. @discardableResult
  195. private func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
  196. guard let bundleIdentifier = Bundle.main.bundleIdentifier,
  197. shortcutItem.type == bundleIdentifier + ".toggleSpeakBG"
  198. else {
  199. return false
  200. }
  201. Storage.shared.speakBG.value.toggle()
  202. let message = Storage.shared.speakBG.value ? "BG Speak is now on" : "BG Speak is now off"
  203. speechSynthesizer.speak(AVSpeechUtterance(string: message))
  204. return true
  205. }
  206. /// With a custom scene delegate installed, UIKit delivers opened URLs here
  207. /// rather than through SwiftUI's onOpenURL, so the Live Activity tap
  208. /// handling from LoopFollowApp is mirrored. Posting twice is harmless —
  209. /// the navigation it triggers is idempotent.
  210. func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
  211. guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return }
  212. #if !targetEnvironment(macCatalyst)
  213. DispatchQueue.main.async {
  214. NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
  215. }
  216. #endif
  217. }
  218. }
  219. extension AppDelegate: UNUserNotificationCenterDelegate {
  220. func userNotificationCenter(_: UNUserNotificationCenter,
  221. willPresent notification: UNNotification,
  222. withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
  223. {
  224. let content = notification.request.content
  225. let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted()
  226. LogManager.shared.log(
  227. category: .general,
  228. message: "Will present notification: keys=\(userInfoKeys), interruption=\(content.interruptionLevel.rawValue), title=\(content.title.isEmpty ? "empty" : "set"), body=\(content.body.isEmpty ? "empty" : "set")"
  229. )
  230. // Suppress notifications iOS routes here that we never intended to surface:
  231. // the Live Activity push-to-start uses interruption-level: passive with empty
  232. // title/body and must not produce a banner or sound when LF is foregrounded.
  233. if content.interruptionLevel == .passive || (content.title.isEmpty && content.body.isEmpty) {
  234. completionHandler([])
  235. return
  236. }
  237. completionHandler([.banner, .sound, .badge])
  238. }
  239. }