AppDelegate.swift 14 KB

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