Storage+Migrate.swift 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. // LoopFollow
  2. // Storage+Migrate.swift
  3. import Foundation
  4. import UserNotifications
  5. extension Storage {
  6. func migrateStep5() {
  7. // Users upgrading from main had snoozer hardcoded at position 3, but that
  8. // key never existed in UserDefaults. On dev the default for snoozerPosition
  9. // is .menu, so an unstored value silently becomes .menu and snoozer
  10. // disappears from the tab bar.
  11. //
  12. // Detect this by checking .exists: if snoozerPosition was never stored,
  13. // the user expects snoozer at position 3 (where it always was on main).
  14. guard !snoozerPosition.exists else { return }
  15. LogManager.shared.log(category: .general, message: "migrateStep5: snoozerPosition not stored, restoring snoozer to position 3")
  16. // If position 3 is occupied by another item whose position also wasn't
  17. // stored (e.g. nightscout landing there from a changed default), move
  18. // that item out first.
  19. for item in TabItem.allCases where item != .snoozer {
  20. if position(for: item).normalized == .position3,
  21. !migratePositionExists(for: item)
  22. {
  23. setPosition(.menu, for: item)
  24. }
  25. }
  26. if tabItem(at: .position3) == nil {
  27. snoozerPosition.value = .position3
  28. }
  29. }
  30. func migrateStep8() {
  31. // Migrate showTITR bool + custom lowLine/highLine into timeInRangeModeRaw.
  32. LogManager.shared.log(category: .general, message: "Running migrateStep8 — timeInRangeMode migration")
  33. if showTITR.value {
  34. timeInRangeModeRaw.value = TimeInRangeDisplayMode.titr.rawValue
  35. } else if lowLine.value != 70.0 || highLine.value != 180.0 {
  36. timeInRangeModeRaw.value = TimeInRangeDisplayMode.custom.rawValue
  37. }
  38. // else: default "TIR" is already set
  39. }
  40. func migrateStep7() {
  41. // Cancel notifications scheduled with old hardcoded identifiers.
  42. // Replaced with bundle-ID-scoped identifiers for multi-instance support.
  43. LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers")
  44. let legacyNotificationIDs = [
  45. "loopfollow.background.alert.6min",
  46. "loopfollow.background.alert.12min",
  47. "loopfollow.background.alert.18min",
  48. "loopfollow.la.renewal.failed",
  49. ]
  50. UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs)
  51. UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs)
  52. }
  53. func migrateStep10() {
  54. LogManager.shared.log(category: .general, message: "Running migrateStep10 — infoSort/infoVisible → infoDisplayItems")
  55. migrateInfoDisplayItems()
  56. }
  57. /// Converts the legacy parallel `infoSort` / `infoVisible` arrays into the
  58. /// unified `infoDisplayItems` store, then removes the legacy keys.
  59. ///
  60. /// Idempotent — does nothing once the legacy keys are gone. Called from
  61. /// `migrateStep10()` and, as a safety net, unconditionally from
  62. /// `synchronizeInfoTypes()`: fresh installs never persist a `migrationStep`,
  63. /// so a user who customized their info display before this release would
  64. /// otherwise have the step-10 guard skip their conversion.
  65. func migrateInfoDisplayItems() {
  66. let legacySort = StorageValue<[Int]>(key: "infoSort", defaultValue: [])
  67. let legacyVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: [])
  68. guard legacySort.exists || legacyVisible.exists else { return }
  69. let sort = legacySort.value
  70. let visible = legacyVisible.value
  71. var items: [InfoDisplayItem] = []
  72. var seen = Set<Int>()
  73. // Honor the saved order and per-index visibility.
  74. for index in sort {
  75. guard let type = InfoType(rawValue: index), seen.insert(index).inserted else { continue }
  76. let isVisible = index < visible.count ? visible[index] : type.defaultVisible
  77. items.append(InfoDisplayItem(type: type, isVisible: isVisible, coloring: InfoColoring()))
  78. }
  79. // Append any InfoType missing from the saved order (new cases, gaps).
  80. for type in InfoType.allCases where seen.insert(type.rawValue).inserted {
  81. items.append(InfoDisplayItem(type: type, isVisible: type.defaultVisible, coloring: InfoColoring()))
  82. }
  83. Storage.shared.infoDisplayItems.value = items
  84. legacySort.remove()
  85. legacyVisible.remove()
  86. }
  87. func migrateStep9() {
  88. // Default for debugLogLevel changed from false to true so users ship useful
  89. // logs when they report a problem. Force-enable for existing users.
  90. LogManager.shared.log(category: .general, message: "Running migrateStep9 — enabling debug log level")
  91. debugLogLevel.value = true
  92. }
  93. func migrateStep6() {
  94. // APNs credential separation
  95. LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation")
  96. let legacyReturnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
  97. let legacyReturnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
  98. let legacyApnsKey = StorageValue<String>(key: "apnsKey", defaultValue: "")
  99. let legacyKeyId = StorageValue<String>(key: "keyId", defaultValue: "")
  100. // 1. If returnApnsKey had a value, that was LoopFollow's own key (different team scenario)
  101. if legacyReturnApnsKey.exists, !legacyReturnApnsKey.value.isEmpty {
  102. lfApnsKey.value = legacyReturnApnsKey.value
  103. lfKeyId.value = legacyReturnKeyId.value
  104. }
  105. // 2. If lfApnsKey is still empty and the old primary key exists,
  106. // check if same team — if so, the primary key was used for everything
  107. if lfApnsKey.value.isEmpty, legacyApnsKey.exists, !legacyApnsKey.value.isEmpty {
  108. let lfTeamId = BuildDetails.default.teamID ?? ""
  109. let remoteTeamId = teamId.value ?? ""
  110. let sameTeam = !lfTeamId.isEmpty && (remoteTeamId.isEmpty || lfTeamId == remoteTeamId)
  111. if sameTeam {
  112. lfApnsKey.value = legacyApnsKey.value
  113. lfKeyId.value = legacyKeyId.value
  114. }
  115. }
  116. // 3. Move old primary credentials to remoteApnsKey/remoteKeyId
  117. if legacyApnsKey.exists, !legacyApnsKey.value.isEmpty {
  118. remoteApnsKey.value = legacyApnsKey.value
  119. remoteKeyId.value = legacyKeyId.value
  120. }
  121. // 4. Clean up old keys
  122. legacyReturnApnsKey.remove()
  123. legacyReturnKeyId.remove()
  124. legacyApnsKey.remove()
  125. legacyKeyId.remove()
  126. }
  127. func migrateStep3() {
  128. LogManager.shared.log(category: .general, message: "Running migrateStep3 - this should only happen once!")
  129. let legacyForceDarkMode = StorageValue<Bool>(key: "forceDarkMode", defaultValue: true)
  130. if legacyForceDarkMode.exists {
  131. Storage.shared.appearanceMode.value = legacyForceDarkMode.value ? .dark : .system
  132. legacyForceDarkMode.remove()
  133. }
  134. if !TabPosition.customizablePositions.contains(homePosition.value.normalized) {
  135. LogManager.shared.log(category: .general, message: "migrateStep3: Setting home to position1 (was \(homePosition.value.rawValue))")
  136. homePosition.value = .position1
  137. }
  138. if !TabPosition.customizablePositions.contains(snoozerPosition.value.normalized) {
  139. // Move any unstored occupant at position 3 to menu before placing snoozer,
  140. // to avoid a collision when dev defaults differ from main.
  141. for item in TabItem.allCases where item != .snoozer {
  142. if position(for: item).normalized == .position3,
  143. !migratePositionExists(for: item)
  144. {
  145. setPosition(.menu, for: item)
  146. }
  147. }
  148. if tabItem(at: .position3) == nil {
  149. snoozerPosition.value = .position3
  150. }
  151. }
  152. if alarmsPosition.value == .more {
  153. alarmsPosition.value = .menu
  154. }
  155. if remotePosition.value == .more {
  156. remotePosition.value = .menu
  157. }
  158. if nightscoutPosition.value == .more {
  159. nightscoutPosition.value = .menu
  160. }
  161. }
  162. func migrateStep2() {
  163. // Migrate from old system to new position-based system
  164. if remoteType.value != .none {
  165. remotePosition.value = .position2
  166. alarmsPosition.value = .menu
  167. } else {
  168. alarmsPosition.value = .position2
  169. remotePosition.value = .menu
  170. }
  171. nightscoutPosition.value = .position4
  172. }
  173. // MARK: - Migration helpers (can be removed when step 5 is removed)
  174. /// Check whether a tab item's position was explicitly stored in UserDefaults
  175. /// (as opposed to using the StorageValue default).
  176. private func migratePositionExists(for item: TabItem) -> Bool {
  177. switch item {
  178. case .home: return homePosition.exists
  179. case .alarms: return alarmsPosition.exists
  180. case .remote: return remotePosition.exists
  181. case .nightscout: return nightscoutPosition.exists
  182. case .snoozer: return snoozerPosition.exists
  183. case .stats: return statisticsPosition.exists
  184. case .treatments: return treatmentsPosition.exists
  185. }
  186. }
  187. }