TrioApp.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import BackgroundTasks
  2. import CoreData
  3. import Foundation
  4. import SwiftUI
  5. import Swinject
  6. extension Notification.Name {
  7. static let initializationCompleted = Notification.Name("initializationCompleted")
  8. static let initializationError = Notification.Name("initializationError")
  9. static let onboardingCompleted = Notification.Name("onboardingCompleted")
  10. }
  11. @main struct TrioApp: App {
  12. @Environment(\.scenePhase) var scenePhase
  13. @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
  14. // Read the color scheme preference from UserDefaults; defaults to system default setting
  15. @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
  16. let coreDataStack = CoreDataStack.shared
  17. let onboardingManager = OnboardingManager.shared
  18. class InitState {
  19. var complete: Bool = false
  20. var error: Bool = false
  21. var migrationErrors: [String] = []
  22. var migrationFailed: Bool = false
  23. }
  24. // We use both InitState and @State variables to track coreDataStack
  25. // initialization. We need both to handle the cases when the coreDataStack
  26. // finishes before the UI and when it finishes after. SwiftUI doesn't have
  27. // clean mechanisms for handling background thread updates, thus this solution.
  28. let initState = InitState()
  29. @State private var appState = AppState()
  30. @State private var showLoadingView = true
  31. @State private var showLoadingError = false
  32. @State private var showOnboardingCompletedSplash = false
  33. @State private var showMigrationError: Bool = false
  34. // Telemetry: one-shot guard so the consent migration sheet is presented
  35. // at most once per process even if scene activates repeatedly.
  36. @State private var showTelemetryMigrationSheet = false
  37. @State private var hasCheckedTelemetryMigration = false
  38. // Dependencies Assembler
  39. // contain all dependencies Assemblies
  40. // TODO: Remove static key after update "Use Dependencies" logic
  41. private static var assembler = Assembler([
  42. StorageAssembly(),
  43. ServiceAssembly(),
  44. APSAssembly(),
  45. NetworkAssembly(),
  46. UIAssembly(),
  47. SecurityAssembly()
  48. ], parent: nil, defaultObjectScope: .container)
  49. // Simple thread-safe wrapper
  50. private static let resolverLock = NSRecursiveLock()
  51. var resolver: Resolver {
  52. TrioApp.resolver
  53. }
  54. static var resolver: Resolver {
  55. // Return a simple wrapper that adds locking
  56. LockedResolver(resolver: assembler.resolver, lock: resolverLock)
  57. }
  58. private func loadServices() {
  59. resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance()
  60. _ = resolver.resolve(DeviceDataManager.self)!
  61. _ = resolver.resolve(APSManager.self)!
  62. _ = resolver.resolve(FetchGlucoseManager.self)!
  63. _ = resolver.resolve(FetchTreatmentsManager.self)!
  64. _ = resolver.resolve(CalendarManager.self)!
  65. _ = resolver.resolve(UserNotificationsManager.self)!
  66. _ = resolver.resolve(WatchManager.self)!
  67. _ = resolver.resolve(ContactImageManager.self)!
  68. _ = resolver.resolve(HealthKitManager.self)!
  69. _ = resolver.resolve(WatchManager.self)!
  70. _ = resolver.resolve(GarminManager.self)!
  71. _ = resolver.resolve(ContactImageManager.self)!
  72. _ = resolver.resolve(BluetoothStateManager.self)!
  73. _ = resolver.resolve(PluginManager.self)!
  74. _ = resolver.resolve(AlertPermissionsChecker.self)!
  75. if #available(iOS 16.2, *) {
  76. _ = resolver.resolve(LiveActivityManager.self)!
  77. }
  78. _ = resolver.resolve(IOBService.self)!
  79. }
  80. init() {
  81. FileProtectionFixer.fixFlagFileProtectionForPropertyPersistentFlags() // TODO: ‼️ REMOVE ME BEFORE PUBLIC BETA / RELEASE
  82. let notificationCenter = Foundation.NotificationCenter.default
  83. notificationCenter.addObserver(
  84. forName: .initializationCompleted,
  85. object: nil,
  86. queue: .main
  87. ) { [self] _ in
  88. showLoadingView = false
  89. }
  90. notificationCenter.addObserver(
  91. forName: .initializationError,
  92. object: nil,
  93. queue: .main
  94. ) { [self] _ in
  95. showLoadingError = true
  96. }
  97. notificationCenter.addObserver(
  98. forName: .onboardingCompleted,
  99. object: nil,
  100. queue: .main
  101. ) { [self] _ in
  102. showOnboardingCompletedSplash = true
  103. }
  104. let submodulesInfo = BuildDetails.shared.submodules.map { key, value in
  105. "\(key): \(value.branch) \(value.commitSHA)"
  106. }.joined(separator: ", ")
  107. /// The current development version of the app.
  108. ///
  109. /// Follows a semantic pattern where release versions are like `0.5.0`, and
  110. /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
  111. /// after the base release. For example:
  112. /// - After release `0.5.0` → `0.5.0`
  113. /// - First dev push → `0.5.0.1`
  114. /// - Next dev push → `0.5.0.2`
  115. /// - Next release `0.6.0` → `0.6.0`
  116. /// - Next dev push → `0.6.0.1`
  117. ///
  118. /// If the dev version is unavailable, `"unknown"` is returned.
  119. let devVersion = Bundle.main.appDevVersion ?? "unknown"
  120. debug(
  121. .default,
  122. "Trio Started: v\(devVersion)(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [Branch: \(BuildDetails.shared.branchAndSha)] [submodules: \(submodulesInfo)]"
  123. )
  124. // Fix bug in iOS 18 related to the translucent tab bar
  125. configureTabBarAppearance()
  126. deferredInitialization()
  127. }
  128. /// Handles the deferred initialization of core components.
  129. ///
  130. /// Performs CoreDataStack initialization asynchronously and notifies the UI
  131. /// of completion or errors via notifications.
  132. private func deferredInitialization() {
  133. Task {
  134. do {
  135. try await coreDataStack.initializeStack()
  136. // TODO: possibly wrap this in a UserDefault / TinyStorage flag check, so we do not even attempt to fetch files unnecessary, but early exit the import
  137. await performJsonToCoreDataMigrationIfNeeded()
  138. await Task { @MainActor in
  139. // Only load services after successful Core Data initialization
  140. loadServices()
  141. // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
  142. cleanupOldData()
  143. self.initState.complete = true
  144. // Notifications handling
  145. // Notify of completed initialization
  146. Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
  147. UIApplication.shared.registerForRemoteNotifications()
  148. // Cancel scheduled not looping notifications when app was completely shut down and has now re-initialized completely
  149. self.clearNotLoopingNotifications()
  150. do {
  151. try await BuildDetails.shared.handleExpireDateChange()
  152. } catch {
  153. debug(.default, "Failed to handle expire date change: \(error)")
  154. }
  155. }.value
  156. } catch {
  157. debug(
  158. .coreData,
  159. "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error)"
  160. )
  161. await MainActor.run {
  162. self.initState.error = true
  163. Foundation.NotificationCenter.default.post(name: .initializationError, object: nil)
  164. }
  165. }
  166. }
  167. }
  168. @MainActor private func performJsonToCoreDataMigrationIfNeeded() async {
  169. let importer = JSONImporter(context: coreDataStack.newTaskContext(), coreDataStack: coreDataStack)
  170. var importErrors: [String] = []
  171. do {
  172. try await importer.importGlucoseHistoryIfNeeded()
  173. } catch {
  174. importErrors
  175. .append(String(localized: "Failed to import glucose history."))
  176. debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)")
  177. }
  178. do {
  179. try await importer.importPumpHistoryIfNeeded()
  180. } catch {
  181. importErrors.append(String(localized: "Failed to import pump history."))
  182. debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)")
  183. }
  184. do {
  185. try await importer.importCarbHistoryIfNeeded()
  186. } catch {
  187. importErrors.append(String(localized: "Failed to import algorithm data."))
  188. debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)")
  189. }
  190. do {
  191. try await importer.importDeterminationIfNeeded()
  192. } catch {
  193. importErrors
  194. .append(
  195. String(localized: "Migration of JSON-based OpenAPS Determination Data failed: \(error.localizedDescription)")
  196. )
  197. debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)")
  198. }
  199. initState.migrationErrors = importErrors
  200. initState.migrationFailed = importErrors.isNotEmpty
  201. }
  202. /// Clears any legacy (Trio 0.2.x) delivered and pending notifications related to non-looping alerts.
  203. /// It targets the following notifications:
  204. /// - `noLoopFirstNotification`: The first notification for non-looping alerts.
  205. /// - `noLoopSecondNotification`: The second notification for non-looping alerts.
  206. ///
  207. /// It ensures that any notifications that have already been shown to the user, as well as
  208. /// any that are scheduled for the future, are removed when the system no longer needs to
  209. /// alert about non-looping conditions.
  210. ///
  211. /// This function is typically used when the app was completely shut down and restarted,
  212. /// i.e., underwent a fresh initialization and boot-up, to avoid bogus not looping notifications
  213. /// due to dangling "zombie" pending notification requests for users that update from
  214. /// old Trio versions to the new generation of the app.
  215. ///
  216. /// Delivered notifications are cleared for completeness.
  217. private func clearNotLoopingNotifications() {
  218. let legacyNoLoopFirstNotification = "FreeAPS.noLoopFirstNotification"
  219. let legacyNoLoopSecondNotification = "FreeAPS.noLoopSecondNotification"
  220. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [
  221. legacyNoLoopFirstNotification,
  222. legacyNoLoopSecondNotification
  223. ])
  224. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [
  225. legacyNoLoopFirstNotification,
  226. legacyNoLoopSecondNotification
  227. ])
  228. }
  229. /// Attempts to initialize the CoreDataStack again after a previous failure.
  230. ///
  231. /// Resets error states and triggers the initialization process from the beginning. Called in response
  232. /// to a UI "retry" button press from the Main.LoadingView
  233. private func retryCoreDataInitialization() {
  234. showLoadingError = false
  235. initState.error = false
  236. deferredInitialization()
  237. }
  238. var body: some Scene {
  239. WindowGroup {
  240. ZStack {
  241. if self.showLoadingView {
  242. Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
  243. .onAppear {
  244. if self.initState.complete {
  245. Task { @MainActor in
  246. try? await Task.sleep(for: .seconds(1.8))
  247. self.showLoadingView = false
  248. if self.initState.migrationErrors.isNotEmpty {
  249. self.showMigrationError = true
  250. }
  251. }
  252. }
  253. if self.initState.error {
  254. self.showLoadingError = true
  255. }
  256. }
  257. .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
  258. Task { @MainActor in
  259. try? await Task.sleep(for: .seconds(1.8))
  260. self.showLoadingView = false
  261. if self.initState.migrationErrors.isNotEmpty {
  262. self.showMigrationError = true
  263. }
  264. }
  265. }
  266. .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
  267. self.showLoadingError = true
  268. }
  269. } else if showMigrationError { // FIXME: display of this is not yet working, despite migration errors
  270. Main.MainMigrationErrorView(migrationErrors: self.initState.migrationErrors, onConfirm: {
  271. Task { @MainActor in
  272. showMigrationError = false
  273. initState.migrationErrors = []
  274. }
  275. })
  276. } else if showOnboardingCompletedSplash {
  277. LogoBurstSplash(isActive: $showOnboardingCompletedSplash) {
  278. Main.RootView(resolver: resolver)
  279. .preferredColorScheme(colorScheme(for: colorSchemePreference))
  280. .environment(
  281. \.managedObjectContext,
  282. coreDataStack.persistentContainer.viewContext
  283. )
  284. .environment(appState)
  285. .environmentObject(Icons())
  286. .onOpenURL(perform: handleURL)
  287. }
  288. } else if onboardingManager.shouldShowOnboarding {
  289. // Show onboarding if needed
  290. Onboarding.RootView(
  291. resolver: resolver,
  292. onboardingManager: onboardingManager,
  293. wasMigrationSuccessful: !initState.migrationFailed
  294. )
  295. .preferredColorScheme(colorScheme(for: .dark) ?? nil)
  296. .transition(.opacity)
  297. } else {
  298. Main.RootView(resolver: resolver)
  299. .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
  300. .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
  301. .environment(appState)
  302. .environmentObject(Icons())
  303. .onOpenURL(perform: handleURL)
  304. }
  305. }
  306. .onReceive(Foundation.NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in
  307. Task { @MainActor in
  308. self.showOnboardingCompletedSplash = true
  309. }
  310. }
  311. .sheet(isPresented: $showTelemetryMigrationSheet) {
  312. TelemetryMigrationSheetView()
  313. .interactiveDismissDisabled(true)
  314. }
  315. }
  316. .onChange(of: scenePhase) { _, newScenePhase in
  317. debug(.default, "APPLICATION PHASE: \(newScenePhase)")
  318. /// If the App goes to the background we should ensure that all the changes are saved from the viewContext to the Persistent Container
  319. if newScenePhase == .background {
  320. coreDataStack.save()
  321. }
  322. if newScenePhase == .active {
  323. if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
  324. let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController
  325. {
  326. AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC)
  327. }
  328. if initState.complete {
  329. performCleanupIfNecessary()
  330. }
  331. presentTelemetryMigrationSheetIfNeeded()
  332. }
  333. }
  334. }
  335. /// Presents the one-time telemetry consent sheet for users who completed
  336. /// onboarding before telemetry existed. The condition (`onboardingCompleted
  337. /// == true` and no telemetry decision yet) is checked once per process —
  338. /// the in-app dismiss handler sets `telemetryConsentDecisionMade`, so a
  339. /// re-foreground after the user picks will no longer match.
  340. private func presentTelemetryMigrationSheetIfNeeded() {
  341. guard !hasCheckedTelemetryMigration else { return }
  342. hasCheckedTelemetryMigration = true
  343. let onboarded = PropertyPersistentFlags.shared.onboardingCompleted == true
  344. let telemetryDecided = PropertyPersistentFlags.shared.telemetryConsentDecisionMade == true
  345. guard onboarded, !telemetryDecided else { return }
  346. // Defer one runloop so SwiftUI has finished settling on whatever root
  347. // view was just shown (loading screen, splash, main view).
  348. DispatchQueue.main.async {
  349. showTelemetryMigrationSheet = true
  350. }
  351. }
  352. func configureTabBarAppearance() {
  353. let appearance = UITabBarAppearance()
  354. appearance.configureWithDefaultBackground()
  355. appearance.backgroundEffect = UIBlurEffect(style: .systemChromeMaterial)
  356. appearance.backgroundColor = UIColor.clear
  357. UITabBar.appearance().standardAppearance = appearance
  358. UITabBar.appearance().scrollEdgeAppearance = appearance
  359. }
  360. private func colorScheme(for colorScheme: ColorSchemeOption) -> ColorScheme? {
  361. switch colorScheme {
  362. case .systemDefault:
  363. return nil // Uses the system theme.
  364. case .light:
  365. return .light
  366. case .dark:
  367. return .dark
  368. }
  369. }
  370. private func performCleanupIfNecessary() {
  371. if let lastCleanupDate = PropertyPersistentFlags.shared.lastCleanupDate {
  372. let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
  373. if lastCleanupDate < sevenDaysAgo {
  374. cleanupOldData()
  375. }
  376. }
  377. }
  378. private func cleanupOldData() {
  379. Task {
  380. async let cleanupTokens: () = coreDataStack.cleanupPersistentHistoryTokens(before: Date.oneWeekAgo)
  381. async let purgeData: () = purgeOldNSManagedObjects()
  382. await cleanupTokens
  383. try await purgeData
  384. // Update the last cleanup date
  385. PropertyPersistentFlags.shared.lastCleanupDate = Date()
  386. }
  387. }
  388. private func purgeOldNSManagedObjects() async throws {
  389. async let glucoseDeletion: () = coreDataStack.batchDeleteOlderThan(GlucoseStored.self, dateKey: "date", days: 90)
  390. async let archivedGlucoseDeletion: () = coreDataStack
  391. .batchDeleteOlderThan(DeletedGlucoseStored.self, dateKey: "date", days: 90)
  392. async let pumpEventDeletion: () = coreDataStack.batchDeleteOlderThan(PumpEventStored.self, dateKey: "timestamp", days: 90)
  393. async let bolusDeletion: () = coreDataStack.batchDeleteOlderThan(
  394. parentType: PumpEventStored.self,
  395. childType: BolusStored.self,
  396. dateKey: "timestamp",
  397. days: 90,
  398. relationshipKey: "pumpEvent"
  399. )
  400. async let tempBasalDeletion: () = coreDataStack.batchDeleteOlderThan(
  401. parentType: PumpEventStored.self,
  402. childType: TempBasalStored.self,
  403. dateKey: "timestamp",
  404. days: 90,
  405. relationshipKey: "pumpEvent"
  406. )
  407. async let determinationDeletion: () = coreDataStack
  408. .batchDeleteOlderThan(OrefDetermination.self, dateKey: "deliverAt", days: 90)
  409. async let batteryDeletion: () = coreDataStack.batchDeleteOlderThan(OpenAPS_Battery.self, dateKey: "date", days: 90)
  410. async let carbEntryDeletion: () = coreDataStack.batchDeleteOlderThan(CarbEntryStored.self, dateKey: "date", days: 90)
  411. async let forecastDeletion: () = coreDataStack.batchDeleteOlderThan(Forecast.self, dateKey: "date", days: 2)
  412. async let forecastValueDeletion: () = coreDataStack.batchDeleteOlderThan(
  413. parentType: Forecast.self,
  414. childType: ForecastValue.self,
  415. dateKey: "date",
  416. days: 2,
  417. relationshipKey: "forecast"
  418. )
  419. async let overrideDeletion: () = coreDataStack
  420. .batchDeleteOlderThan(OverrideStored.self, dateKey: "date", days: 3, isPresetKey: "isPreset")
  421. async let overrideRunDeletion: () = coreDataStack
  422. .batchDeleteOlderThan(OverrideRunStored.self, dateKey: "startDate", days: 3)
  423. // Await each task to ensure they are all completed
  424. try await glucoseDeletion
  425. try await archivedGlucoseDeletion
  426. try await pumpEventDeletion
  427. try await bolusDeletion
  428. try await tempBasalDeletion
  429. try await determinationDeletion
  430. try await batteryDeletion
  431. try await carbEntryDeletion
  432. try await forecastDeletion
  433. try await forecastValueDeletion
  434. try await overrideDeletion
  435. try await overrideRunDeletion
  436. }
  437. private func handleURL(_ url: URL) {
  438. let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
  439. switch components?.host {
  440. case "device-select-resp":
  441. resolver.resolve(NotificationCenter.self)!.post(name: .openFromGarminConnect, object: url)
  442. default: break
  443. }
  444. }
  445. }
  446. public extension Bundle {
  447. var appDevVersion: String? {
  448. object(forInfoDictionaryKey: "AppDevVersion") as? String
  449. }
  450. }