TrioApp.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  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 = false
  20. var error = false
  21. var migrationErrors: [String] = []
  22. }
  23. // We use both InitState and @State variables to track coreDataStack
  24. // initialization. We need both to handle the cases when the coreDataStack
  25. // finishes before the UI and when it finishes after. SwiftUI doesn't have
  26. // clean mechanisms for handling background thread updates, thus this solution.
  27. let initState = InitState()
  28. @State private var appState = AppState()
  29. @State private var showLoadingView = true
  30. @State private var showLoadingError = false
  31. @State private var showOnboardingCompletedSplash = false
  32. @State private var showMigrationError: Bool = false
  33. // Dependencies Assembler
  34. // contain all dependencies Assemblies
  35. // TODO: Remove static key after update "Use Dependencies" logic
  36. private static var assembler = Assembler([
  37. StorageAssembly(),
  38. ServiceAssembly(),
  39. APSAssembly(),
  40. NetworkAssembly(),
  41. UIAssembly(),
  42. SecurityAssembly()
  43. ], parent: nil, defaultObjectScope: .container)
  44. var resolver: Resolver {
  45. TrioApp.assembler.resolver
  46. }
  47. // Temp static var
  48. // Use to backward compatibility with old Dependencies logic on Logger
  49. // TODO: Remove var after update "Use Dependencies" logic in Logger
  50. static var resolver: Resolver {
  51. TrioApp.assembler.resolver
  52. }
  53. private func loadServices() {
  54. resolver.resolve(AppearanceManager.self)!.setupGlobalAppearance()
  55. _ = resolver.resolve(DeviceDataManager.self)!
  56. _ = resolver.resolve(APSManager.self)!
  57. _ = resolver.resolve(FetchGlucoseManager.self)!
  58. _ = resolver.resolve(FetchTreatmentsManager.self)!
  59. _ = resolver.resolve(CalendarManager.self)!
  60. _ = resolver.resolve(UserNotificationsManager.self)!
  61. _ = resolver.resolve(WatchManager.self)!
  62. _ = resolver.resolve(ContactImageManager.self)!
  63. _ = resolver.resolve(HealthKitManager.self)!
  64. _ = resolver.resolve(WatchManager.self)!
  65. _ = resolver.resolve(GarminManager.self)!
  66. _ = resolver.resolve(ContactImageManager.self)!
  67. _ = resolver.resolve(BluetoothStateManager.self)!
  68. _ = resolver.resolve(PluginManager.self)!
  69. _ = resolver.resolve(AlertPermissionsChecker.self)!
  70. if #available(iOS 16.2, *) {
  71. _ = resolver.resolve(LiveActivityManager.self)!
  72. }
  73. }
  74. init() {
  75. let notificationCenter = Foundation.NotificationCenter.default
  76. notificationCenter.addObserver(
  77. forName: .initializationCompleted,
  78. object: nil,
  79. queue: .main
  80. ) { [self] _ in
  81. showLoadingView = false
  82. }
  83. notificationCenter.addObserver(
  84. forName: .initializationError,
  85. object: nil,
  86. queue: .main
  87. ) { [self] _ in
  88. showLoadingError = true
  89. }
  90. notificationCenter.addObserver(
  91. forName: .onboardingCompleted,
  92. object: nil,
  93. queue: .main
  94. ) { [self] _ in
  95. showOnboardingCompletedSplash = true
  96. }
  97. let submodulesInfo = BuildDetails.shared.submodules.map { key, value in
  98. "\(key): \(value.branch) \(value.commitSHA)"
  99. }.joined(separator: ", ")
  100. debug(
  101. .default,
  102. "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [Branch: \(BuildDetails.shared.branchAndSha)] [submodules: \(submodulesInfo)]"
  103. )
  104. // Fix bug in iOS 18 related to the translucent tab bar
  105. configureTabBarAppearance()
  106. deferredInitialization()
  107. }
  108. /// Handles the deferred initialization of core components.
  109. ///
  110. /// Performs CoreDataStack initialization asynchronously and notifies the UI
  111. /// of completion or errors via notifications.
  112. private func deferredInitialization() {
  113. Task {
  114. do {
  115. try await coreDataStack.initializeStack()
  116. // 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
  117. await performJsonToCoreDataMigrationIfNeeded()
  118. await Task { @MainActor in
  119. // Only load services after successful Core Data initialization
  120. loadServices()
  121. // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
  122. cleanupOldData()
  123. self.initState.complete = true
  124. // Notifications handling
  125. // Notify of completed initialization
  126. Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
  127. UIApplication.shared.registerForRemoteNotifications()
  128. // Cancel scheduled not looping notifications when app was completely shut down and has now re-initialized completely
  129. self.clearNotLoopingNotifications()
  130. do {
  131. try await BuildDetails.shared.handleExpireDateChange()
  132. } catch {
  133. debug(.default, "Failed to handle expire date change: \(error)")
  134. }
  135. }.value
  136. } catch {
  137. debug(
  138. .coreData,
  139. "\(DebuggingIdentifiers.failed) Failed to initialize Core Data Stack: \(error.localizedDescription)"
  140. )
  141. await MainActor.run {
  142. self.initState.error = true
  143. Foundation.NotificationCenter.default.post(name: .initializationError, object: nil)
  144. }
  145. }
  146. }
  147. }
  148. @MainActor private func performJsonToCoreDataMigrationIfNeeded() async {
  149. let importer = JSONImporter(context: coreDataStack.newTaskContext(), coreDataStack: coreDataStack)
  150. var importErrors: [String] = []
  151. do {
  152. try await importer.importGlucoseHistoryIfNeeded()
  153. } catch {
  154. importErrors
  155. .append(String(localized: "Failed to import glucose history."))
  156. debug(.coreData, "❌ Failed to import JSON-based Glucose History: \(error)")
  157. }
  158. do {
  159. try await importer.importPumpHistoryIfNeeded()
  160. } catch {
  161. importErrors.append(String(localized: "Failed to import pump history."))
  162. debug(.coreData, "❌ Failed to import JSON-based Pump History: \(error)")
  163. }
  164. do {
  165. try await importer.importCarbHistoryIfNeeded()
  166. } catch {
  167. importErrors.append(String(localized: "Failed to import algorithm data."))
  168. debug(.coreData, "❌ Failed to import JSON-based Carb History: \(error)")
  169. }
  170. do {
  171. try await importer.importDeterminationIfNeeded()
  172. } catch {
  173. importErrors
  174. .append(
  175. String(localized: "Migration of JSON-based OpenAPS Determination Data failed: \(error.localizedDescription)")
  176. )
  177. debug(.coreData, "❌ Failed to import JSON-based OpenAPS Determination Data: \(error)")
  178. }
  179. initState.migrationErrors = importErrors
  180. }
  181. /// Clears any legacy (Trio 0.2.x) delivered and pending notifications related to non-looping alerts.
  182. /// It targets the following notifications:
  183. /// - `noLoopFirstNotification`: The first notification for non-looping alerts.
  184. /// - `noLoopSecondNotification`: The second notification for non-looping alerts.
  185. ///
  186. /// It ensures that any notifications that have already been shown to the user, as well as
  187. /// any that are scheduled for the future, are removed when the system no longer needs to
  188. /// alert about non-looping conditions.
  189. ///
  190. /// This function is typically used when the app was completely shut down and restarted,
  191. /// i.e., underwent a fresh initialization and boot-up, to avoid bogus not looping notifications
  192. /// due to dangling "zombie" pending notification requests for users that update from
  193. /// old Trio versions to the new generation of the app.
  194. ///
  195. /// Delivered notifications are cleared for completeness.
  196. private func clearNotLoopingNotifications() {
  197. let legacyNoLoopFirstNotification = "FreeAPS.noLoopFirstNotification"
  198. let legacyNoLoopSecondNotification = "FreeAPS.noLoopSecondNotification"
  199. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [
  200. legacyNoLoopFirstNotification,
  201. legacyNoLoopSecondNotification
  202. ])
  203. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [
  204. legacyNoLoopFirstNotification,
  205. legacyNoLoopSecondNotification
  206. ])
  207. }
  208. /// Attempts to initialize the CoreDataStack again after a previous failure.
  209. ///
  210. /// Resets error states and triggers the initialization process from the beginning. Called in response
  211. /// to a UI "retry" button press from the Main.LoadingView
  212. private func retryCoreDataInitialization() {
  213. showLoadingError = false
  214. initState.error = false
  215. deferredInitialization()
  216. }
  217. var body: some Scene {
  218. WindowGroup {
  219. ZStack {
  220. if self.showLoadingView {
  221. Main.LoadingView(showError: $showLoadingError, retry: retryCoreDataInitialization)
  222. .onAppear {
  223. if self.initState.complete {
  224. Task { @MainActor in
  225. try? await Task.sleep(for: .seconds(1.8))
  226. self.showLoadingView = false
  227. if self.initState.migrationErrors.isNotEmpty {
  228. self.showMigrationError = true
  229. }
  230. }
  231. }
  232. if self.initState.error {
  233. self.showLoadingError = true
  234. }
  235. }
  236. .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationCompleted)) { _ in
  237. Task { @MainActor in
  238. try? await Task.sleep(for: .seconds(1.8))
  239. self.showLoadingView = false
  240. if self.initState.migrationErrors.isNotEmpty {
  241. self.showMigrationError = true
  242. }
  243. }
  244. }
  245. .onReceive(Foundation.NotificationCenter.default.publisher(for: .initializationError)) { _ in
  246. self.showLoadingError = true
  247. }
  248. } else if showMigrationError { // FIXME: display of this is not yet working, despite migration errors
  249. Main.MainMigrationErrorView(migrationErrors: self.initState.migrationErrors, onConfirm: {
  250. Task { @MainActor in
  251. showMigrationError = false
  252. initState.migrationErrors = []
  253. }
  254. })
  255. } else if showOnboardingCompletedSplash {
  256. LogoBurstSplash(isActive: $showOnboardingCompletedSplash) {
  257. Main.RootView(resolver: resolver)
  258. .preferredColorScheme(colorScheme(for: colorSchemePreference))
  259. .environment(
  260. \.managedObjectContext,
  261. coreDataStack.persistentContainer.viewContext
  262. )
  263. .environment(appState)
  264. .environmentObject(Icons())
  265. .onOpenURL(perform: handleURL)
  266. }
  267. } else if onboardingManager.shouldShowOnboarding {
  268. // Show onboarding if needed
  269. Onboarding.RootView(resolver: resolver, onboardingManager: onboardingManager)
  270. .preferredColorScheme(colorScheme(for: .dark) ?? nil)
  271. .transition(.opacity)
  272. } else {
  273. Main.RootView(resolver: resolver)
  274. .preferredColorScheme(colorScheme(for: colorSchemePreference) ?? nil)
  275. .environment(\.managedObjectContext, coreDataStack.persistentContainer.viewContext)
  276. .environment(appState)
  277. .environmentObject(Icons())
  278. .onOpenURL(perform: handleURL)
  279. }
  280. }
  281. .onReceive(Foundation.NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in
  282. Task { @MainActor in
  283. self.showOnboardingCompletedSplash = true
  284. }
  285. }
  286. }
  287. .onChange(of: scenePhase) { _, newScenePhase in
  288. debug(.default, "APPLICATION PHASE: \(newScenePhase)")
  289. /// If the App goes to the background we should ensure that all the changes are saved from the viewContext to the Persistent Container
  290. if newScenePhase == .background {
  291. coreDataStack.save()
  292. }
  293. if newScenePhase == .active {
  294. if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
  295. let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController
  296. {
  297. AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC)
  298. }
  299. if initState.complete {
  300. performCleanupIfNecessary()
  301. }
  302. }
  303. }
  304. }
  305. func configureTabBarAppearance() {
  306. let appearance = UITabBarAppearance()
  307. appearance.configureWithDefaultBackground()
  308. appearance.backgroundEffect = UIBlurEffect(style: .systemChromeMaterial)
  309. appearance.backgroundColor = UIColor.clear
  310. UITabBar.appearance().standardAppearance = appearance
  311. UITabBar.appearance().scrollEdgeAppearance = appearance
  312. }
  313. private func colorScheme(for colorScheme: ColorSchemeOption) -> ColorScheme? {
  314. switch colorScheme {
  315. case .systemDefault:
  316. return nil // Uses the system theme.
  317. case .light:
  318. return .light
  319. case .dark:
  320. return .dark
  321. }
  322. }
  323. private func performCleanupIfNecessary() {
  324. if let lastCleanupDate = PropertyPersistentFlags.shared.lastCleanupDate {
  325. let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60)
  326. if lastCleanupDate < sevenDaysAgo {
  327. cleanupOldData()
  328. }
  329. }
  330. }
  331. private func cleanupOldData() {
  332. Task {
  333. async let cleanupTokens: () = coreDataStack.cleanupPersistentHistoryTokens(before: Date.oneWeekAgo)
  334. async let purgeData: () = purgeOldNSManagedObjects()
  335. await cleanupTokens
  336. try await purgeData
  337. // Update the last cleanup date
  338. PropertyPersistentFlags.shared.lastCleanupDate = Date()
  339. }
  340. }
  341. private func purgeOldNSManagedObjects() async throws {
  342. async let glucoseDeletion: () = coreDataStack.batchDeleteOlderThan(GlucoseStored.self, dateKey: "date", days: 90)
  343. async let pumpEventDeletion: () = coreDataStack.batchDeleteOlderThan(PumpEventStored.self, dateKey: "timestamp", days: 90)
  344. async let bolusDeletion: () = coreDataStack.batchDeleteOlderThan(
  345. parentType: PumpEventStored.self,
  346. childType: BolusStored.self,
  347. dateKey: "timestamp",
  348. days: 90,
  349. relationshipKey: "pumpEvent"
  350. )
  351. async let tempBasalDeletion: () = coreDataStack.batchDeleteOlderThan(
  352. parentType: PumpEventStored.self,
  353. childType: TempBasalStored.self,
  354. dateKey: "timestamp",
  355. days: 90,
  356. relationshipKey: "pumpEvent"
  357. )
  358. async let determinationDeletion: () = coreDataStack
  359. .batchDeleteOlderThan(OrefDetermination.self, dateKey: "deliverAt", days: 90)
  360. async let batteryDeletion: () = coreDataStack.batchDeleteOlderThan(OpenAPS_Battery.self, dateKey: "date", days: 90)
  361. async let carbEntryDeletion: () = coreDataStack.batchDeleteOlderThan(CarbEntryStored.self, dateKey: "date", days: 90)
  362. async let forecastDeletion: () = coreDataStack.batchDeleteOlderThan(Forecast.self, dateKey: "date", days: 2)
  363. async let forecastValueDeletion: () = coreDataStack.batchDeleteOlderThan(
  364. parentType: Forecast.self,
  365. childType: ForecastValue.self,
  366. dateKey: "date",
  367. days: 2,
  368. relationshipKey: "forecast"
  369. )
  370. async let overrideDeletion: () = coreDataStack
  371. .batchDeleteOlderThan(OverrideStored.self, dateKey: "date", days: 3, isPresetKey: "isPreset")
  372. async let overrideRunDeletion: () = coreDataStack
  373. .batchDeleteOlderThan(OverrideRunStored.self, dateKey: "startDate", days: 3)
  374. // Await each task to ensure they are all completed
  375. try await glucoseDeletion
  376. try await pumpEventDeletion
  377. try await bolusDeletion
  378. try await tempBasalDeletion
  379. try await determinationDeletion
  380. try await batteryDeletion
  381. try await carbEntryDeletion
  382. try await forecastDeletion
  383. try await forecastValueDeletion
  384. try await overrideDeletion
  385. try await overrideRunDeletion
  386. }
  387. private func handleURL(_ url: URL) {
  388. let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
  389. switch components?.host {
  390. case "device-select-resp":
  391. resolver.resolve(NotificationCenter.self)!.post(name: .openFromGarminConnect, object: url)
  392. default: break
  393. }
  394. }
  395. }