AppDelegate.swift 13 KB

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