// LoopFollow // Storage.swift import Foundation import HealthKit import UIKit /* Observable persistant storage */ class Storage { var remoteType = StorageValue(key: "remoteType", defaultValue: .none) var deviceToken = StorageValue(key: "deviceToken", defaultValue: "") var expirationDate = StorageValue(key: "expirationDate", defaultValue: nil) var sharedSecret = StorageValue(key: "sharedSecret", defaultValue: "") var productionEnvironment = StorageValue(key: "productionEnvironment", defaultValue: false) var remoteApnsKey = StorageValue(key: "remoteApnsKey", defaultValue: "") var teamId = StorageValue(key: "teamId", defaultValue: nil) var remoteKeyId = StorageValue(key: "remoteKeyId", defaultValue: "") var lfApnsKey = StorageValue(key: "lfApnsKey", defaultValue: "") var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") var maxBolus = SecureStorageValue(key: "maxBolus", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1.0)) var maxCarbs = SecureStorageValue(key: "maxCarbs", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) var maxProtein = SecureStorageValue(key: "maxProtein", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) var maxFat = SecureStorageValue(key: "maxFat", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) var mealWithBolus = StorageValue(key: "mealWithBolus", defaultValue: false) var mealWithFatProtein = StorageValue(key: "mealWithFatProtein", defaultValue: false) // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: true) var backgroundRefreshType = StorageValue(key: "backgroundRefreshType", defaultValue: .silentTune) var selectedBLEDevice = StorageValue(key: "selectedBLEDevice", defaultValue: nil) var debugLogLevel = StorageValue(key: "debugLogLevel", defaultValue: true) var contactTrend = StorageValue(key: "contactTrend", defaultValue: .off) var contactDelta = StorageValue(key: "contactDelta", defaultValue: .off) var contactIOB = StorageValue(key: "contactIOB", defaultValue: .off) var contactTrendTarget = StorageValue(key: "contactTrendTarget", defaultValue: .BG) var contactDeltaTarget = StorageValue(key: "contactDeltaTarget", defaultValue: .BG) var contactIOBTarget = StorageValue(key: "contactIOBTarget", defaultValue: .BG) var contactEnabled = StorageValue(key: "contactEnabled", defaultValue: false) var contactBackgroundColor = StorageValue(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue) var contactTextColor = StorageValue(key: "contactTextColor", defaultValue: ContactColorOption.white.rawValue) var contactColorMode = StorageValue(key: "contactColorMode", defaultValue: .staticColor) var sensorScheduleOffset = StorageValue(key: "sensorScheduleOffset", defaultValue: nil) var alarms = StorageValue<[Alarm]>(key: "alarms", defaultValue: []) var alarmConfiguration = StorageValue(key: "alarmConfiguration", defaultValue: .default) var lastOverrideStartNotified = StorageValue(key: "lastOverrideStartNotified", defaultValue: nil) var lastOverrideEndNotified = StorageValue(key: "lastOverrideEndNotified", defaultValue: nil) var lastTempTargetStartNotified = StorageValue(key: "lastTempTargetStartNotified", defaultValue: nil) var lastTempTargetEndNotified = StorageValue(key: "lastTempTargetEndNotified", defaultValue: nil) var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) var pendingFutureCarbs = StorageValue<[PendingFutureCarb]>(key: "pendingFutureCarbs", defaultValue: []) // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) var showSmallGraph = StorageValue(key: "showSmallGraph", defaultValue: true) var screenlockSwitchState = StorageValue(key: "screenlockSwitchState", defaultValue: true) var showDisplayName = StorageValue(key: "showDisplayName", defaultValue: false) var snoozerEmoji = StorageValue(key: "snoozerEmoji", defaultValue: false) var forcePortraitMode = StorageValue(key: "forcePortraitMode", defaultValue: false) var speakBG = StorageValue(key: "speakBG", defaultValue: false) var speakBGAlways = StorageValue(key: "speakBGAlways", defaultValue: true) var speakLowBG = StorageValue(key: "speakLowBG", defaultValue: false) var speakProactiveLowBG = StorageValue(key: "speakProactiveLowBG", defaultValue: false) var speakFastDropDelta = StorageValue(key: "speakFastDropDelta", defaultValue: 10.0) var speakLowBGLimit = StorageValue(key: "speakLowBGLimit", defaultValue: 72.0) var speakHighBGLimit = StorageValue(key: "speakHighBGLimit", defaultValue: 180.0) var speakHighBG = StorageValue(key: "speakHighBG", defaultValue: false) var speakLanguage = StorageValue(key: "speakLanguage", defaultValue: "en") // General Settings [END] // Live Activity glucose state var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) var lastTargetHighMgdl = StorageValue(key: "lastTargetHighMgdl", defaultValue: nil) var lastIsfMgdlPerU = StorageValue(key: "lastIsfMgdlPerU", defaultValue: nil) var lastCarbRatio = StorageValue(key: "lastCarbRatio", defaultValue: nil) var lastCarbsToday = StorageValue(key: "lastCarbsToday", defaultValue: nil) var lastProfileName = StorageValue(key: "lastProfileName", defaultValue: "") var iageInsertTime = StorageValue(key: "iageInsertTime", defaultValue: 0) var lastMinBgMgdl = StorageValue(key: "lastMinBgMgdl", defaultValue: nil) var lastMaxBgMgdl = StorageValue(key: "lastMaxBgMgdl", defaultValue: nil) // Live Activity var laEnabled = StorageValue(key: "laEnabled", defaultValue: false) var laRenewBy = StorageValue(key: "laRenewBy", defaultValue: 0) var laRenewalFailed = StorageValue(key: "laRenewalFailed", defaultValue: false) var laPushToStartToken = StorageValue(key: "laPushToStartToken", defaultValue: "") var laLastPushToStartAt = StorageValue(key: "laLastPushToStartAt", defaultValue: 0) var laPushToStartBackoff = StorageValue(key: "laPushToStartBackoff", defaultValue: 0) // Graph Settings [BEGIN] var showDots = StorageValue(key: "showDots", defaultValue: true) var showLines = StorageValue(key: "showLines", defaultValue: true) var showValues = StorageValue(key: "showValues", defaultValue: true) var showAbsorption = StorageValue(key: "showAbsorption", defaultValue: true) var showDIALines = StorageValue(key: "showDIAMarkers", defaultValue: true) var show30MinLine = StorageValue(key: "show30MinLine", defaultValue: false) var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) var predictionToLoad = StorageValue(key: "predictionToLoad", defaultValue: 1.0) var predictionDisplayType = StorageValue(key: "predictionDisplayType", defaultValue: .cone) var minBasalScale = StorageValue(key: "minBasalScale", defaultValue: 5.0) var minBGScale = StorageValue(key: "minBGScale", defaultValue: 250.0) var lowLine = StorageValue(key: "lowLine", defaultValue: 70.0) var highLine = StorageValue(key: "highLine", defaultValue: 180.0) var downloadDays = StorageValue(key: "downloadDays", defaultValue: 1) var graphTimeZoneEnabled = StorageValue(key: "graphTimeZoneEnabled", defaultValue: false) var graphTimeZoneIdentifier = StorageValue(key: "graphTimeZoneIdentifier", defaultValue: TimeZone.current.identifier) // Graph Settings [END] // Calendar entries [BEGIN] var writeCalendarEvent = StorageValue(key: "writeCalendarEvent", defaultValue: false) var calendarIdentifier = StorageValue(key: "calendarIdentifier", defaultValue: "") var watchLine1 = StorageValue(key: "watchLine1", defaultValue: "%BG% %DIRECTION% %DELTA% %MINAGO%") var watchLine2 = StorageValue(key: "watchLine2", defaultValue: "C:%COB% I:%IOB% B:%BASAL%") // Calendar entries [END] // MARK: - Dexcom Share -------------------------------------------------------- var shareUserName = StorageValue(key: "shareUserName", defaultValue: "") var sharePassword = StorageValue(key: "sharePassword", defaultValue: "") var shareServer = StorageValue(key: "shareServer", defaultValue: "US") // MARK: - Graph --------------------------------------------------------------- var chartScaleX = StorageValue(key: "chartScaleX", defaultValue: 18.0) // MARK: - Advanced settings --------------------------------------------------- var downloadTreatments = StorageValue(key: "downloadTreatments", defaultValue: true) var downloadPrediction = StorageValue(key: "downloadPrediction", defaultValue: true) var graphOtherTreatments = StorageValue(key: "graphOtherTreatments", defaultValue: true) var graphBasal = StorageValue(key: "graphBasal", defaultValue: true) var graphBolus = StorageValue(key: "graphBolus", defaultValue: true) var graphCarbs = StorageValue(key: "graphCarbs", defaultValue: true) var bgUpdateDelay = StorageValue(key: "bgUpdateDelay", defaultValue: 10) // MARK: - Insert times (sensor / pump) --------------------------------------- var cageInsertTime = StorageValue(key: "cageInsertTime", defaultValue: 0) var sageInsertTime = StorageValue(key: "sageInsertTime", defaultValue: 0) // MARK: - Version-info --------------------------- var cachedForVersion = StorageValue(key: "cachedForVersion", defaultValue: nil) var latestVersion = StorageValue(key: "latestVersion", defaultValue: nil) var latestVersionChecked = StorageValue(key: "latestVersionChecked", defaultValue: nil) var currentVersionBlackListed = StorageValue(key: "currentVersionBlackListed", defaultValue: false) var lastBlacklistNotificationShown = StorageValue(key: "lastBlacklistNotificationShown", defaultValue: nil) var lastVersionUpdateNotificationShown = StorageValue(key: "lastVersionUpdateNotificationShown", defaultValue: nil) var lastExpirationNotificationShown = StorageValue(key: "lastExpirationNotificationShown", defaultValue: nil) // MARK: - Telemetry ----------------------------------------------------------- // See LoopFollow/Helpers/Telemetry.swift. var telemetryEnabled = StorageValue(key: "telemetryEnabled", defaultValue: true) var telemetryConsentDecisionMade = StorageValue(key: "telemetryConsentDecisionMade", defaultValue: false) var telemetryLastSentAt = StorageValue(key: "telemetryLastSentAt", defaultValue: nil) var telemetryLastSentSha = StorageValue(key: "telemetryLastSentSha", defaultValue: "") // Sliding 7-day window of cold-launch timestamps. var telemetryColdLaunchTimes = StorageValue<[Date]>(key: "telemetryColdLaunchTimes", defaultValue: []) var hideInfoTable = StorageValue(key: "hideInfoTable", defaultValue: false) var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") var hasConfiguredUnits = StorageValue(key: "hasConfiguredUnits", defaultValue: false) var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") var nsWriteAuth = StorageValue(key: "nsWriteAuth", defaultValue: false) var nsAdminAuth = StorageValue(key: "nsAdminAuth", defaultValue: false) var webSocketEnabled = StorageValue(key: "webSocketEnabled", defaultValue: true) // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default // to the new latest step number so fresh installs skip all migrations. Other defaults // in this file must reflect the post-migration final state for a fresh install. var migrationStep = StorageValue(key: "migrationStep", defaultValue: 9) var persistentNotification = StorageValue(key: "persistentNotification", defaultValue: false) var persistentNotificationLastBGTime = StorageValue(key: "persistentNotificationLastBGTime", defaultValue: .distantPast) var lastLoopingChecked = StorageValue(key: "lastLoopingChecked", defaultValue: nil) var lastBGChecked = StorageValue(key: "lastBGChecked", defaultValue: nil) var lastLoopTime = StorageValue(key: "lastLoopTime", defaultValue: 0) // Tab positions - which position each item is in (positions 1-4 are customizable, 5 is always Menu) var homePosition = StorageValue(key: "homePosition", defaultValue: .position1) var alarmsPosition = StorageValue(key: "alarmsPosition", defaultValue: .position2) var snoozerPosition = StorageValue(key: "snoozerPosition", defaultValue: .position3) var nightscoutPosition = StorageValue(key: "nightscoutPosition", defaultValue: .position4) var remotePosition = StorageValue(key: "remotePosition", defaultValue: .menu) var statisticsPosition = StorageValue(key: "statisticsPosition", defaultValue: .menu) var treatmentsPosition = StorageValue(key: "treatmentsPosition", defaultValue: .menu) var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: []) var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: []) // Statistics display preferences var showGMI = StorageValue(key: "showGMI", defaultValue: true) var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) var showTITR = StorageValue(key: "showTITR", defaultValue: false) var timeInRangeModeRaw = StorageValue(key: "timeInRangeMode", defaultValue: "TIR") static let shared = Storage() private init() {} /// Set to true at launch if isProtectedDataAvailable was false (BFU state). /// Consumed and cleared on the first foreground after that launch. var needsBFUReload = false /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background /// launch, where Storage was initialized while UserDefaults was encrypted and all values were /// cached as their defaults. /// /// `migrationStep` is intentionally excluded: viewDidLoad writes it to the latest step during /// the BFU launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run. /// /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same /// BFU inaccessibility; that is a separate problem. func reloadAll() { remoteType.reload() deviceToken.reload() expirationDate.reload() sharedSecret.reload() productionEnvironment.reload() remoteApnsKey.reload() teamId.reload() remoteKeyId.reload() lfApnsKey.reload() lfKeyId.reload() bundleId.reload() user.reload() mealWithBolus.reload() mealWithFatProtein.reload() hasSeenFatProteinOrderChange.reload() backgroundRefreshType.reload() selectedBLEDevice.reload() debugLogLevel.reload() contactTrend.reload() contactDelta.reload() contactEnabled.reload() contactBackgroundColor.reload() contactTextColor.reload() sensorScheduleOffset.reload() alarms.reload() alarmConfiguration.reload() lastOverrideStartNotified.reload() lastOverrideEndNotified.reload() lastTempTargetStartNotified.reload() lastTempTargetEndNotified.reload() lastRecBolusNotified.reload() lastCOBNotified.reload() lastMissedBolusNotified.reload() appBadge.reload() colorBGText.reload() appearanceMode.reload() showStats.reload() useIFCC.reload() showSmallGraph.reload() screenlockSwitchState.reload() showDisplayName.reload() snoozerEmoji.reload() forcePortraitMode.reload() speakBG.reload() speakBGAlways.reload() speakLowBG.reload() speakProactiveLowBG.reload() speakFastDropDelta.reload() speakLowBGLimit.reload() speakHighBGLimit.reload() speakHighBG.reload() speakLanguage.reload() lastBgReadingTimeSeconds.reload() lastDeltaMgdl.reload() lastTrendCode.reload() lastIOB.reload() lastCOB.reload() projectedBgMgdl.reload() lastBasal.reload() lastPumpReservoirU.reload() lastAutosens.reload() lastTdd.reload() lastTargetLowMgdl.reload() lastTargetHighMgdl.reload() lastIsfMgdlPerU.reload() lastCarbRatio.reload() lastCarbsToday.reload() lastProfileName.reload() iageInsertTime.reload() lastMinBgMgdl.reload() lastMaxBgMgdl.reload() laEnabled.reload() laRenewBy.reload() laRenewalFailed.reload() laPushToStartToken.reload() laLastPushToStartAt.reload() laPushToStartBackoff.reload() showDots.reload() showLines.reload() showValues.reload() showAbsorption.reload() showDIALines.reload() show30MinLine.reload() show90MinLine.reload() showMidnightLines.reload() smallGraphTreatments.reload() smallGraphHeight.reload() predictionToLoad.reload() predictionDisplayType.reload() minBasalScale.reload() minBGScale.reload() lowLine.reload() highLine.reload() downloadDays.reload() graphTimeZoneEnabled.reload() graphTimeZoneIdentifier.reload() writeCalendarEvent.reload() calendarIdentifier.reload() watchLine1.reload() watchLine2.reload() shareUserName.reload() sharePassword.reload() shareServer.reload() chartScaleX.reload() downloadTreatments.reload() downloadPrediction.reload() graphOtherTreatments.reload() graphBasal.reload() graphBolus.reload() graphCarbs.reload() bgUpdateDelay.reload() cageInsertTime.reload() sageInsertTime.reload() cachedForVersion.reload() latestVersion.reload() latestVersionChecked.reload() currentVersionBlackListed.reload() lastBlacklistNotificationShown.reload() lastVersionUpdateNotificationShown.reload() lastExpirationNotificationShown.reload() hideInfoTable.reload() token.reload() units.reload() infoSort.reload() infoVisible.reload() url.reload() device.reload() nsWriteAuth.reload() nsAdminAuth.reload() // migrationStep intentionally excluded — see method comment above. persistentNotification.reload() persistentNotificationLastBGTime.reload() lastLoopingChecked.reload() lastBGChecked.reload() lastLoopTime.reload() homePosition.reload() alarmsPosition.reload() snoozerPosition.reload() nightscoutPosition.reload() remotePosition.reload() statisticsPosition.reload() treatmentsPosition.reload() loopAPNSQrCodeURL.reload() bolusIncrementDetected.reload() remoteBolusHistory.reload() remoteMealHistory.reload() showGMI.reload() showStdDev.reload() showTITR.reload() timeInRangeModeRaw.reload() } // MARK: - Tab Position Helpers /// Get the position for a given tab item func position(for item: TabItem) -> TabPosition { switch item { case .home: homePosition.value case .alarms: alarmsPosition.value case .remote: remotePosition.value case .nightscout: nightscoutPosition.value case .snoozer: snoozerPosition.value case .stats: statisticsPosition.value case .treatments: treatmentsPosition.value } } /// Set the position for a given tab item func setPosition(_ position: TabPosition, for item: TabItem) { switch item { case .home: homePosition.value = position case .alarms: alarmsPosition.value = position case .remote: remotePosition.value = position case .nightscout: nightscoutPosition.value = position case .snoozer: snoozerPosition.value = position case .stats: statisticsPosition.value = position case .treatments: treatmentsPosition.value = position } } /// Get the tab item at a specific position (nil if no item at that position) func tabItem(at position: TabPosition) -> TabItem? { for item in TabItem.allCases { // Use normalized comparison to handle legacy values (.more, .disabled -> .menu) if self.position(for: item).normalized == position.normalized { return item } } return nil } /// Get all items in the Menu (position 5) func itemsInMenu() -> [TabItem] { TabItem.featureOrder.filter { position(for: $0).normalized == .menu } } /// Get items ordered by their position in the tab bar (positions 1-4) func orderedTabBarItems() -> [TabItem] { TabPosition.customizablePositions.compactMap { tabItem(at: $0) } } }