AppDelegate.swift 13 KB

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