diff --git a/Common/hi.lproj/Intents.strings b/Common/hi.lproj/Intents.strings new file mode 100644 index 0000000000..853af215c0 --- /dev/null +++ b/Common/hi.lproj/Intents.strings @@ -0,0 +1,24 @@ +"80eo5o" = "Add Carb Entry"; + +"9KhaIS" = "I've set the preset"; + +"I4OZy8" = "Enable Override Preset"; + +"OcNxIj" = "Add Carb Entry"; + +"XNNmtH" = "Enable preset in Loop"; + +"ZZ3mtM" = "Enable an override preset in Loop"; + +"b085BW" = "I wasn't able to set the preset."; + +"lYMuWV" = "Override Name"; + +"nDKAmn" = "What's the name of the override you'd like to set?"; + +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +"yBzwCL" = "Override Selection"; + +"yc02Yq" = "Add a carb entry to Loop"; + diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index eab2ef9b28..dcc947db00 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -24,7 +24,7 @@ 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */; }; 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */; }; 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */; }; - 14B1736928AED9EE006CCD7C /* SmallStatusWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* SmallStatusWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */; }; @@ -480,7 +480,7 @@ C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */; }; C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; C1F00C78285A8256006302C5 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - C1F2075C26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */; }; + C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */; }; C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; @@ -721,7 +721,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 14B1736928AED9EE006CCD7C /* SmallStatusWidgetExtension.appex in Embed App Extensions */, + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */, E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */, 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */, ); @@ -753,7 +753,7 @@ 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryViewModel.swift; sourceTree = ""; }; 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryView.swift; sourceTree = ""; }; 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodDetailView.swift; sourceTree = ""; }; - 14B1735C28AED9EC006CCD7C /* SmallStatusWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; name = SmallStatusWidgetExtension.appex; path = "Loop Widget Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Widget Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 14B1736628AED9EE006CCD7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -789,6 +789,7 @@ 1DC63E7325351BDF004605DA /* TrueTime.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TrueTime.framework; path = Carthage/Build/iOS/TrueTime.framework; sourceTree = ""; }; 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertSchedulerTests.swift; sourceTree = ""; }; + 3D03C6DA2AACE6AC00FDE5D2 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Intents.strings; sourceTree = ""; }; 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewController.swift; sourceTree = ""; }; 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryTableViewController.swift; sourceTree = ""; }; 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; @@ -1567,7 +1568,7 @@ C1EB0D22299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/ckcomplication.strings; sourceTree = ""; }; C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; - C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileExpirationAlerter.swift; sourceTree = ""; }; + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF72995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF82995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; @@ -1996,7 +1997,7 @@ 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, 43D9002A21EB209400AF44BF /* LoopCore.framework */, E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, - 14B1735C28AED9EC006CCD7C /* SmallStatusWidgetExtension.appex */, + 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, ); name = Products; sourceTree = ""; @@ -2312,7 +2313,7 @@ 1DA6499D2441266400F61E75 /* Alerts */, E95D37FF24EADE68005E2F50 /* Store Protocols */, E9B355232935906B0076AB04 /* Missed Meal Detection */, - C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, @@ -2986,7 +2987,7 @@ ); name = "Loop Widget Extension"; productName = SmallStatusWidgetExtension; - productReference = 14B1735C28AED9EC006CCD7C /* SmallStatusWidgetExtension.appex */; + productReference = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; productType = "com.apple.product-type.app-extension"; }; 43776F8B1B8022E90074EA36 /* Loop */ = { @@ -3655,7 +3656,7 @@ C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, - C1F2075C26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift in Sources */, + C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, @@ -4232,6 +4233,7 @@ C1C3127F297E4C0400296DA4 /* ar */, C1C247882995823200371B88 /* sk */, C1C5357529C6346A00E32DF9 /* cs */, + 3D03C6DA2AACE6AC00FDE5D2 /* hi */, ); name = Intents.intentdefinition; sourceTree = ""; @@ -4833,6 +4835,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_DEBUG)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -4877,6 +4883,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -5134,6 +5144,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -5160,6 +5171,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -5413,6 +5425,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -5435,6 +5451,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -5498,6 +5518,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_DEBUG)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -5521,6 +5545,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = 1; }; name = Release; diff --git a/Loop/Managers/AppExpirationAlerter.swift b/Loop/Managers/AppExpirationAlerter.swift new file mode 100644 index 0000000000..e7768f92b6 --- /dev/null +++ b/Loop/Managers/AppExpirationAlerter.swift @@ -0,0 +1,159 @@ +// +// AppExpirationAlerter.swift +// Loop +// +// Created by Pete Schwamb on 8/21/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import UserNotifications +import LoopCore + + +class AppExpirationAlerter { + + static let expirationAlertWindow: TimeInterval = .days(20) + static let settingsPageExpirationWarningModeWindow: TimeInterval = .days(3) + + static func alertIfNeeded(viewControllerToPresentFrom: UIViewController) { + + let now = Date() + + guard let profileExpiration = BuildDetails.default.profileExpiration else { + return + } + + let expirationDate = calculateExpirationDate(profileExpiration: profileExpiration) + + let timeUntilExpiration = expirationDate.timeIntervalSince(now) + + if timeUntilExpiration > expirationAlertWindow { + return + } + + let minimumTimeBetweenAlerts: TimeInterval = timeUntilExpiration > .hours(24) ? .days(2) : .hours(1) + + if let lastAlertDate = UserDefaults.appGroup?.lastProfileExpirationAlertDate { + guard now > lastAlertDate + minimumTimeBetweenAlerts else { + return + } + } + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour] + formatter.unitsStyle = .full + formatter.zeroFormattingBehavior = .dropLeading + formatter.maximumUnitCount = 1 + let timeUntilExpirationStr = formatter.string(from: timeUntilExpiration) + + let alertMessage = createVerboseAlertMessage(timeUntilExpirationStr: timeUntilExpirationStr!) + + var dialog: UIAlertController + if isTestFlightBuild() { + dialog = UIAlertController( + title: NSLocalizedString("TestFlight Expires Soon", comment: "The title for notification of upcoming TestFlight expiration"), + message: alertMessage, + preferredStyle: .alert) + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming TestFlight expiration"), style: .default, handler: nil)) + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming TestFlight expiration"), style: .default, handler: { (_) in + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/gh-actions/gh-update/")!) + })) + + } else { + dialog = UIAlertController( + title: NSLocalizedString("Profile Expires Soon", comment: "The title for notification of upcoming profile expiration"), + message: alertMessage, + preferredStyle: .alert) + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming profile expiration"), style: .default, handler: nil)) + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming profile expiration"), style: .default, handler: { (_) in + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/build/updating/")!) + })) + } + viewControllerToPresentFrom.present(dialog, animated: true, completion: nil) + + UserDefaults.appGroup?.lastProfileExpirationAlertDate = now + } + + static func createVerboseAlertMessage(timeUntilExpirationStr:String) -> String { + if isTestFlightBuild() { + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to rebuild before that.", comment: "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) + } else { + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to update before that, with a new provisioning profile.", comment: "Format string for body for notification of upcoming provisioning profile expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) + } + } + + static func isNearExpiration(expirationDate:Date) -> Bool { + return expirationDate.timeIntervalSinceNow < settingsPageExpirationWarningModeWindow + } + + static func createProfileExpirationSettingsMessage(expirationDate:Date) -> String { + let nearExpiration = isNearExpiration(expirationDate: expirationDate) + let maxUnitCount = nearExpiration ? 2 : 1 // only include hours in the msg if near expiration + let readableRelativeTime: String? = relativeTimeFormatter(maxUnitCount: maxUnitCount).string(from: expirationDate.timeIntervalSinceNow) + let relativeTimeRemaining: String = readableRelativeTime ?? NSLocalizedString("Unknown time", comment: "Unknown amount of time in settings' profile expiration section") + let verboseMessage = createVerboseAlertMessage(timeUntilExpirationStr: relativeTimeRemaining) + let conciseMessage = relativeTimeRemaining + NSLocalizedString(" remaining", comment: "remaining time in setting's profile expiration section") + return nearExpiration ? verboseMessage : conciseMessage + } + + private static func relativeTimeFormatter(maxUnitCount:Int) -> DateComponentsFormatter { + let formatter = DateComponentsFormatter() + let includeHours = maxUnitCount == 2 + formatter.allowedUnits = includeHours ? [.day, .hour] : [.day] + formatter.unitsStyle = .full + formatter.zeroFormattingBehavior = .dropLeading + formatter.maximumUnitCount = maxUnitCount + return formatter + } + + static func buildDate() -> Date? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEE MMM d HH:mm:ss 'UTC' yyyy" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Set locale to ensure parsing works + dateFormatter.timeZone = TimeZone(identifier: "UTC") + + guard let dateString = BuildDetails.default.buildDateString, + let date = dateFormatter.date(from: dateString) else { + return nil + } + + return date + } + + static func isTestFlightBuild() -> Bool { + // If the target environment is a simulator, then + // this is not a TestFlight distribution. Return false. + #if targetEnvironment(simulator) + return false + #endif + + // If an "embedded.mobileprovision" is present in the main bundle, then + // this is an Xcode, Ad-Hoc, or Enterprise distribution. Return false. + if Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") != nil { + return false + } + + // If an app store receipt is not present in the main bundle, then we cannot + // say whether this is a TestFlight or App Store distribution. Return false. + guard let receiptName = Bundle.main.appStoreReceiptURL?.lastPathComponent else { + return false + } + + // A TestFlight distribution presents a "sandboxReceipt", while an App Store + // distribution presents a "receipt". Return true if we have a TestFlight receipt. + return "sandboxReceipt".caseInsensitiveCompare(receiptName) == .orderedSame + } + + static func calculateExpirationDate(profileExpiration: Date) -> Date { + let isTestFlight = isTestFlightBuild() + + if isTestFlight, let buildDate = buildDate() { + let testflightExpiration = Calendar.current.date(byAdding: .day, value: 90, to: buildDate)! + + return profileExpiration < testflightExpiration ? profileExpiration : testflightExpiration + } else { + return profileExpiration + } + } +} diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2697df7569..b6cd35d3a6 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -157,6 +157,8 @@ final class DeviceDataManager { let glucoseStore: GlucoseStore + let cgmEventStore: CgmEventStore + private let cacheStore: PersistenceController let dosingDecisionStore: DosingDecisionStore @@ -339,11 +341,13 @@ final class DeviceDataManager { cgmStalenessMonitor = CGMStalenessMonitor() cgmStalenessMonitor.delegate = glucoseStore + + cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) + + dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) - self.dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) - - self.cgmHasValidSensorSession = false - self.pumpIsAllowingAutomation = true + cgmHasValidSensorSession = false + pumpIsAllowingAutomation = true self.automaticDosingStatus = automaticDosingStatus // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then @@ -407,6 +411,7 @@ final class DeviceDataManager { doseStore: doseStore, dosingDecisionStore: dosingDecisionStore, glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, settingsStore: settingsManager.settingsStore, overrideHistory: overrideHistory, insulinDeliveryStore: doseStore.insulinDeliveryStore @@ -439,6 +444,7 @@ final class DeviceDataManager { doseStore.delegate = self dosingDecisionStore.delegate = self glucoseStore.delegate = self + cgmEventStore.delegate = self doseStore.insulinDeliveryStore.delegate = self remoteDataServicesManager.delegate = self @@ -992,6 +998,16 @@ extension DeviceDataManager: CGMManagerDelegate { } } + func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { + Task { + do { + try await cgmEventStore.add(events: events) + } catch { + self.log.error("Error storing cgm events: %{public}@", error.localizedDescription) + } + } + } + func startDateToFilterNewData(for manager: CGMManager) -> Date? { dispatchPrecondition(condition: .onQueue(queue)) return glucoseStore.latestGlucose?.startDate @@ -1182,11 +1198,17 @@ extension DeviceDataManager: PumpManagerDelegate { setLastError(error: error) } - func pumpManager(_ pumpManager: PumpManager, hasNewPumpEvents events: [NewPumpEvent], lastReconciliation: Date?, completion: @escaping (_ error: Error?) -> Void) { + func pumpManager( + _ pumpManager: PumpManager, + hasNewPumpEvents events: [NewPumpEvent], + lastReconciliation: Date?, + replacePendingEvents: Bool, + completion: @escaping (_ error: Error?) -> Void) + { dispatchPrecondition(condition: .onQueue(queue)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) - loopManager.addPumpEvents(events, lastReconciliation: lastReconciliation) { (error) in + doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in if let error = error { self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) } @@ -1249,60 +1271,56 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { // MARK: - AlertStoreDelegate extension DeviceDataManager: AlertStoreDelegate { - func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) { - remoteDataServicesManager.alertStoreHasUpdatedAlertData(alertStore) + remoteDataServicesManager.triggerUpload(for: .alert) } - } // MARK: - CarbStoreDelegate extension DeviceDataManager: CarbStoreDelegate { - func carbStoreHasUpdatedCarbData(_ carbStore: CarbStore) { - remoteDataServicesManager.carbStoreHasUpdatedCarbData(carbStore) + remoteDataServicesManager.triggerUpload(for: .carb) } func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError) {} - } // MARK: - DoseStoreDelegate extension DeviceDataManager: DoseStoreDelegate { - func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { - remoteDataServicesManager.doseStoreHasUpdatedPumpEventData(doseStore) + remoteDataServicesManager.triggerUpload(for: .pumpEvent) } - } // MARK: - DosingDecisionStoreDelegate extension DeviceDataManager: DosingDecisionStoreDelegate { - func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore) { - remoteDataServicesManager.dosingDecisionStoreHasUpdatedDosingDecisionData(dosingDecisionStore) + remoteDataServicesManager.triggerUpload(for: .dosingDecision) } - } // MARK: - GlucoseStoreDelegate extension DeviceDataManager: GlucoseStoreDelegate { - func glucoseStoreHasUpdatedGlucoseData(_ glucoseStore: GlucoseStore) { - remoteDataServicesManager.glucoseStoreHasUpdatedGlucoseData(glucoseStore) + remoteDataServicesManager.triggerUpload(for: .glucose) } - } // MARK: - InsulinDeliveryStoreDelegate extension DeviceDataManager: InsulinDeliveryStoreDelegate { - func insulinDeliveryStoreHasUpdatedDoseData(_ insulinDeliveryStore: InsulinDeliveryStore) { - remoteDataServicesManager.insulinDeliveryStoreHasUpdatedDoseData(insulinDeliveryStore) + remoteDataServicesManager.triggerUpload(for: .dose) } +} +// MARK: - CgmEventStoreDelegate +extension DeviceDataManager: CgmEventStoreDelegate { + func cgmEventStoreHasUpdatedData(_ cgmEventStore: LoopKit.CgmEventStore) { + remoteDataServicesManager.triggerUpload(for: .cgmEvent) + } } + // MARK: - TestingPumpManager extension DeviceDataManager { func deleteTestingPumpData(completion: ((Error?) -> Void)? = nil) { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 0f441651b5..b8e23d0bba 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -321,7 +321,7 @@ class LoopAppManager: NSObject { func didBecomeActive() { if let rootViewController = rootViewController { - ProfileExpirationAlerter.alertIfNeeded(viewControllerToPresentFrom: rootViewController) + AppExpirationAlerter.alertIfNeeded(viewControllerToPresentFrom: rootViewController) } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() @@ -601,7 +601,7 @@ extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { UserDefaults.appGroup?.overrideHistory = history - deviceDataManager.remoteDataServicesManager.temporaryScheduleOverrideHistoryDidUpdate() + deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d50fddb73f..b56cddd35b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -741,26 +741,6 @@ extension LoopDataManager { } } - - /// Adds and stores new pump events - /// - /// - Parameters: - /// - events: The pump events to add - /// - completion: A closure called once upon completion - /// - lastReconciliation: The date that pump events were most recently reconciled against recorded pump history. Pump events are assumed to be reflective of delivery up until this point in time. If reservoir values are recorded after this time, they may be used to supplement event based delivery. - /// - error: An error explaining why the events could not be saved. - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) { - doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation) { (error) in - completion(error) - - self.dataAccessQueue.async { - if error == nil { - self.clearCachedInsulinEffects() - } - } - } - } - /// Logs a new external bolus insulin dose in the DoseStore and HealthKit /// /// - Parameters: diff --git a/Loop/Managers/ProfileExpirationAlerter.swift b/Loop/Managers/ProfileExpirationAlerter.swift deleted file mode 100644 index 3aa742732b..0000000000 --- a/Loop/Managers/ProfileExpirationAlerter.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// ProfileExpirationAlerter.swift -// Loop -// -// Created by Pete Schwamb on 8/21/21. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import Foundation -import UserNotifications -import LoopCore - - -class ProfileExpirationAlerter { - - static let expirationAlertWindow: TimeInterval = .days(20) - static let settingsPageExpirationWarningModeWindow: TimeInterval = .days(3) - - static func alertIfNeeded(viewControllerToPresentFrom: UIViewController) { - - let now = Date() - - guard let profileExpiration = BuildDetails.default.profileExpiration, now > profileExpiration - expirationAlertWindow else { - return - } - - let timeUntilExpiration = profileExpiration.timeIntervalSince(now) - - let minimumTimeBetweenAlerts: TimeInterval = timeUntilExpiration > .hours(24) ? .days(2) : .hours(1) - - if let lastAlertDate = UserDefaults.appGroup?.lastProfileExpirationAlertDate { - guard now > lastAlertDate + minimumTimeBetweenAlerts else { - return - } - } - - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.day, .hour] - formatter.unitsStyle = .full - formatter.zeroFormattingBehavior = .dropLeading - formatter.maximumUnitCount = 1 - let timeUntilExpirationStr = formatter.string(from: timeUntilExpiration) - - let alertMessage = createVerboseAlertMessage(timeUntilExpirationStr: timeUntilExpirationStr!) - - let dialog = UIAlertController( - title: NSLocalizedString("Profile Expires Soon", comment: "The title for notification of upcoming profile expiration"), - message: alertMessage, - preferredStyle: .alert) - dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming profile expiration"), style: .default, handler: nil)) - dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming profile expiration"), style: .default, handler: { (_) in - UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/build/updating/")!) - })) - viewControllerToPresentFrom.present(dialog, animated: true, completion: nil) - - UserDefaults.appGroup?.lastProfileExpirationAlertDate = now - } - - static func createVerboseAlertMessage(timeUntilExpirationStr:String) -> String { - return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to update before that, with a new provisioning profile.", comment: "Format string for body for notification of upcoming provisioning profile expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) - } - - static func isNearProfileExpiration(profileExpiration:Date) -> Bool { - return profileExpiration.timeIntervalSinceNow < settingsPageExpirationWarningModeWindow - } - - static func createProfileExpirationSettingsMessage(profileExpiration:Date) -> String { - let nearExpiration = isNearProfileExpiration(profileExpiration: profileExpiration) - let maxUnitCount = nearExpiration ? 2 : 1 // only include hours in the msg if near expiration - let readableRelativeTime: String? = relativeTimeFormatter(maxUnitCount: maxUnitCount).string(from: profileExpiration.timeIntervalSinceNow) - let relativeTimeRemaining: String = readableRelativeTime ?? NSLocalizedString("Unknown time", comment: "Unknown amount of time in settings' profile expiration section") - let verboseMessage = createVerboseAlertMessage(timeUntilExpirationStr: relativeTimeRemaining) - let conciseMessage = relativeTimeRemaining + NSLocalizedString(" remaining", comment: "remaining time in setting's profile expiration section") - return nearExpiration ? verboseMessage : conciseMessage - } - - private static func relativeTimeFormatter(maxUnitCount:Int) -> DateComponentsFormatter { - let formatter = DateComponentsFormatter() - let includeHours = maxUnitCount == 2 - formatter.allowedUnits = includeHours ? [.day, .hour] : [.day] - formatter.unitsStyle = .full - formatter.zeroFormattingBehavior = .dropLeading - formatter.maximumUnitCount = maxUnitCount - return formatter; - } -} diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 36c460e3c3..296e3befa9 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -10,13 +10,14 @@ import os.log import Foundation import LoopKit -enum RemoteDataType: String { +enum RemoteDataType: String, CaseIterable { case alert = "Alert" case carb = "Carb" case dose = "Dose" case dosingDecision = "DosingDecision" case glucose = "Glucose" case pumpEvent = "PumpEvent" + case cgmEvent = "CgmEvent" case settings = "Settings" case overrides = "Overrides" @@ -129,6 +130,8 @@ final class RemoteDataServicesManager { private let glucoseStore: GlucoseStore + private let cgmEventStore: CgmEventStore + private let insulinDeliveryStore: InsulinDeliveryStore private let settingsStore: SettingsStore @@ -141,6 +144,7 @@ final class RemoteDataServicesManager { doseStore: DoseStore, dosingDecisionStore: DosingDecisionStore, glucoseStore: GlucoseStore, + cgmEventStore: CgmEventStore, settingsStore: SettingsStore, overrideHistory: TemporaryScheduleOverrideHistory, insulinDeliveryStore: InsulinDeliveryStore @@ -150,6 +154,7 @@ final class RemoteDataServicesManager { self.doseStore = doseStore self.dosingDecisionStore = dosingDecisionStore self.glucoseStore = glucoseStore + self.cgmEventStore = cgmEventStore self.insulinDeliveryStore = insulinDeliveryStore self.settingsStore = settingsStore self.overrideHistory = overrideHistory @@ -167,13 +172,11 @@ final class RemoteDataServicesManager { } private func clearQueryAnchors(for remoteDataService: RemoteDataService) { - clearAlertQueryAnchor(for: remoteDataService) - clearCarbQueryAnchor(for: remoteDataService) - clearDoseQueryAnchor(for: remoteDataService) - clearDosingDecisionQueryAnchor(for: remoteDataService) - clearGlucoseQueryAnchor(for: remoteDataService) - clearPumpEventQueryAnchor(for: remoteDataService) - clearSettingsQueryAnchor(for: remoteDataService) + for remoteDataType in RemoteDataType.allCases { + dispatchQueue(for: remoteDataService, withRemoteDataType: remoteDataType).async { + UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: remoteDataType) + } + } } func triggerUpload(for triggeringType: RemoteDataType) { @@ -195,6 +198,8 @@ final class RemoteDataServicesManager { remoteDataServices.forEach { self.uploadGlucoseData(to: $0) } case .pumpEvent: remoteDataServices.forEach { self.uploadPumpEventData(to: $0) } + case .cgmEvent: + remoteDataServices.forEach { self.uploadCgmEventData(to: $0) } case .settings: remoteDataServices.forEach { self.uploadSettingsData(to: $0) } case .overrides: @@ -220,11 +225,6 @@ final class RemoteDataServicesManager { } extension RemoteDataServicesManager { - - public func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) { - triggerUpload(for: .alert) - } - private func uploadAlertData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -257,21 +257,9 @@ extension RemoteDataServicesManager { self.uploadGroup.leave() } } - - private func clearAlertQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .alert).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .alert) - } - } - } extension RemoteDataServicesManager { - - public func carbStoreHasUpdatedCarbData(_ carbStore: CarbStore) { - triggerUpload(for: .carb) - } - private func uploadCarbData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -311,21 +299,9 @@ extension RemoteDataServicesManager { } } } - - private func clearCarbQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .carb).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .carb) - } - } - } extension RemoteDataServicesManager { - - public func insulinDeliveryStoreHasUpdatedDoseData(_ insulinDeliveryStore: InsulinDeliveryStore) { - triggerUpload(for: .dose) - } - private func uploadDoseData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -365,21 +341,9 @@ extension RemoteDataServicesManager { } } } - - private func clearDoseQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .dose).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .dose) - } - } - } extension RemoteDataServicesManager { - - public func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore) { - triggerUpload(for: .dosingDecision) - } - private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -419,21 +383,9 @@ extension RemoteDataServicesManager { } } } - - private func clearDosingDecisionQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .dosingDecision).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision) - } - } - } extension RemoteDataServicesManager { - - public func glucoseStoreHasUpdatedGlucoseData(_ glucoseStore: GlucoseStore) { - triggerUpload(for: .glucose) - } - private func uploadGlucoseData(to remoteDataService: RemoteDataService) { if delegate?.shouldSyncToRemoteService == false { @@ -478,21 +430,9 @@ extension RemoteDataServicesManager { } } } - - private func clearGlucoseQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .glucose).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose) - } - } - } extension RemoteDataServicesManager { - - public func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { - triggerUpload(for: .pumpEvent) - } - private func uploadPumpEventData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -532,21 +472,9 @@ extension RemoteDataServicesManager { } } } - - private func clearPumpEventQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .pumpEvent).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent) - } - } - } extension RemoteDataServicesManager { - - public func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { - triggerUpload(for: .settings) - } - private func uploadSettingsData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -586,21 +514,9 @@ extension RemoteDataServicesManager { } } } - - private func clearSettingsQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .settings).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .settings) - } - } - } extension RemoteDataServicesManager { - - public func temporaryScheduleOverrideHistoryDidUpdate() { - triggerUpload(for: .overrides) - } - private func uploadTemporaryOverrideData(to remoteDataService: RemoteDataService) { uploadGroup.enter() @@ -629,10 +545,46 @@ extension RemoteDataServicesManager { self.uploadGroup.leave() } } +} - private func clearTemporaryOverrideQueryAnchor(for remoteDataService: RemoteDataService) { - dispatchQueue(for: remoteDataService, withRemoteDataType: .overrides).async { - UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides) +extension RemoteDataServicesManager { + private func uploadCgmEventData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) + + dispatchQueue(for: remoteDataService, withRemoteDataType: .cgmEvent).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent) ?? CgmEventStore.QueryAnchor() + var continueUpload = false + + self.cgmEventStore.executeCgmEventQuery(fromQueryAnchor: previousQueryAnchor) { result in + switch result { + case .failure(let error): + self.log.error("Error querying pump event data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadCgmEventData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadPumpEventData(to: remoteDataService) + } } } } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index f7421b97f7..e3fdb60bf7 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -211,7 +211,7 @@ class SettingsManager { // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { - remoteDataServicesManager?.settingsStoreHasUpdatedSettingsData(settingsStore) + remoteDataServicesManager?.triggerUpload(for: .settings) } } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index ebbd104da4..dd21ea2a1f 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -35,8 +35,8 @@ protocol DoseStoreProtocol: AnyObject { var pumpEventQueryAfterDate: Date { get } // MARK: dose management - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) - + func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) + func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (_ value: ReservoirValue?, _ previousValue: ReservoirValue?, _ areStoredValuesContinuous: Bool, _ error: DoseStore.DoseStoreError?) -> Void) func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (_ result: DoseStoreResult<[DoseEntry]>) -> Void) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 795b544b2b..c3ec98b8dd 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -101,7 +101,7 @@ public struct SettingsView: View { supportSection if let profileExpiration = BuildDetails.default.profileExpiration, FeatureFlags.profileExpirationSettingsViewEnabled { - profileExpirationSection(profileExpiration: profileExpiration) + appExpirationSection(profileExpiration: profileExpiration) } } } @@ -466,24 +466,50 @@ extension SettingsView { /* DIY loop specific component to show users the amount of time remaining on their build before a rebuild is necessary. */ - private func profileExpirationSection(profileExpiration:Date) -> some View { - let nearExpiration : Bool = ProfileExpirationAlerter.isNearProfileExpiration(profileExpiration: profileExpiration) - let profileExpirationMsg = ProfileExpirationAlerter.createProfileExpirationSettingsMessage(profileExpiration: profileExpiration) - let readableExpirationTime = Self.dateFormatter.string(from: profileExpiration) + private func appExpirationSection(profileExpiration: Date) -> some View { + let expirationDate = AppExpirationAlerter.calculateExpirationDate(profileExpiration: profileExpiration) + let isTestFlight = AppExpirationAlerter.isTestFlightBuild() + let nearExpiration = AppExpirationAlerter.isNearExpiration(expirationDate: expirationDate) + let profileExpirationMsg = AppExpirationAlerter.createProfileExpirationSettingsMessage(expirationDate: expirationDate) + let readableExpirationTime = Self.dateFormatter.string(from: expirationDate) - return Section(header: SectionHeader(label: NSLocalizedString("App Profile", comment: "Settings app profile section")), - footer: Text(NSLocalizedString("Profile expires ", comment: "Time that profile expires") + readableExpirationTime)) { - if(nearExpiration) { - Text(profileExpirationMsg).foregroundColor(.red) + if isTestFlight { + return createAppExpirationSection( + headerLabel: NSLocalizedString("TestFlight", comment: "Settings app TestFlight section"), + footerLabel: NSLocalizedString("TestFlight expires ", comment: "Time that build expires") + readableExpirationTime, + expirationLabel: NSLocalizedString("TestFlight Expiration", comment: "Settings TestFlight expiration view"), + updateURL: "https://loopkit.github.io/loopdocs/gh-actions/gh-update/", + nearExpiration: nearExpiration, + expirationMessage: profileExpirationMsg + ) + } else { + return createAppExpirationSection( + headerLabel: NSLocalizedString("App Profile", comment: "Settings app profile section"), + footerLabel: NSLocalizedString("Profile expires ", comment: "Time that profile expires") + readableExpirationTime, + expirationLabel: NSLocalizedString("Profile Expiration", comment: "Settings App Profile expiration view"), + updateURL: "https://loopkit.github.io/loopdocs/build/updating/", + nearExpiration: nearExpiration, + expirationMessage: profileExpirationMsg + ) + } + } + + private func createAppExpirationSection(headerLabel: String, footerLabel: String, expirationLabel: String, updateURL: String, nearExpiration: Bool, expirationMessage: String) -> some View { + return Section( + header: SectionHeader(label: headerLabel), + footer: Text(footerLabel) + ) { + if nearExpiration { + Text(expirationMessage).foregroundColor(.red) } else { HStack { - Text("Profile Expiration", comment: "Settings App Profile expiration view") + Text(expirationLabel) Spacer() - Text(profileExpirationMsg).foregroundColor(Color.secondary) + Text(expirationMessage).foregroundColor(Color.secondary) } } Button(action: { - UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/build/updating/")!) + UIApplication.shared.open(URL(string: updateURL)!) }) { Text(NSLocalizedString("How to update (LoopDocs)", comment: "The title text for how to update")) } diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index dc358ec361..207596f31b 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -11,7 +11,6 @@ import LoopKit @testable import Loop class MockDoseStore: DoseStoreProtocol { - var doseHistory: [DoseEntry]? var sensitivitySchedule: InsulinSensitivitySchedule? @@ -55,7 +54,7 @@ class MockDoseStore: DoseStoreProtocol { var lastAddedPumpData: Date - func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, completion: @escaping (DoseStore.DoseStoreError?) -> Void) { + func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (DoseStore.DoseStoreError?) -> Void) { completion(nil) } diff --git a/Scripts/capture-build-details.sh b/Scripts/capture-build-details.sh index 66f827d7c3..6122592374 100755 --- a/Scripts/capture-build-details.sh +++ b/Scripts/capture-build-details.sh @@ -10,10 +10,9 @@ SCRIPT_DIRECTORY="$(dirname "${0}")" error() { echo "ERROR: ${*}" >&2 - echo "Usage: ${SCRIPT} [-r|--git-source-root git-source-root] [-p|--provisioning-profile-path provisioning-profile-path] [-i|--info-plist-path info-plist-path]" >&2 + echo "Usage: ${SCRIPT} [-r|--git-source-root git-source-root] [-p|--provisioning-profile-path provisioning-profile-path]" >&2 echo "Parameters:" >&2 echo " -p|--provisioning-profile-path path to the .mobileprovision provisioning profile file to check for expiration; optional, defaults to \${HOME}/Library/MobileDevice/Provisioning Profiles/\${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" >&2 - echo " -i|--info-plist-path path to the Info.plist file to modify; optional, defaults to \${BUILT_PRODUCTS_DIR}/\${INFOPLIST_PATH}" >&2 exit 1 }