| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- // LoopFollow
- // RemoteSettingsViewModel.swift
- import Combine
- import Foundation
- import HealthKit
- class RemoteSettingsViewModel: ObservableObject {
- @Published var remoteType: RemoteType
- @Published var user: String
- @Published var sharedSecret: String
- @Published var remoteApnsKey: String
- @Published var remoteKeyId: String
- @Published var maxBolus: HKQuantity
- @Published var maxCarbs: HKQuantity
- @Published var maxProtein: HKQuantity
- @Published var maxFat: HKQuantity
- @Published var mealWithBolus: Bool
- @Published var mealWithFatProtein: Bool
- @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio")
- @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop")
- // MARK: - Loop APNS Setup Properties
- @Published var loopDeveloperTeamId: String
- @Published var loopAPNSQrCodeURL: String
- @Published var productionEnvironment: Bool
- @Published var isShowingLoopAPNSScanner: Bool = false
- @Published var loopAPNSErrorMessage: String?
- // MARK: - URL/Token Validation Properties
- @Published var pendingSettings: RemoteCommandSettings?
- @Published var showURLTokenValidation: Bool = false
- @Published var validationMessage: String = ""
- @Published var shouldPromptForURL: Bool = false
- @Published var shouldPromptForToken: Bool = false
- // MARK: - Diagnostics
- @Published var diagnostics = RemoteDiagnostics()
- private let diagnosticsHistoryCap = 1000
- private let futureStartDateTolerance: TimeInterval = 60
- let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown"
- /// Determines if the target app's Team ID is different from this app's build Team ID.
- var areTeamIdsDifferent: Bool {
- // Get LoopFollow's own Team ID from the build details.
- guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty, loopFollowTeamID != "Unknown" else {
- return false
- }
- // The property `loopDeveloperTeamId` holds the value from `Storage.shared.teamId`
- let targetTeamId = loopDeveloperTeamId
- // Determine if a comparison is needed and perform it.
- switch remoteType {
- case .trc, .loopAPNS:
- guard !targetTeamId.isEmpty else {
- return false
- }
- return loopFollowTeamID != targetTeamId
- case .none, .nightscout:
- return false
- }
- }
- // MARK: - Computed property for Loop APNS Setup validation
- var loopAPNSSetup: Bool {
- let hasCredentials: Bool
- if areTeamIdsDifferent {
- hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty
- } else {
- hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty
- }
- return hasCredentials &&
- !loopDeveloperTeamId.isEmpty &&
- !loopAPNSQrCodeURL.isEmpty &&
- !Storage.shared.deviceToken.value.isEmpty &&
- !Storage.shared.bundleId.value.isEmpty
- }
- private var storage = Storage.shared
- private var cancellables = Set<AnyCancellable>()
- init() {
- // Initialize published properties from storage
- remoteType = storage.remoteType.value
- user = storage.user.value
- sharedSecret = storage.sharedSecret.value
- remoteApnsKey = storage.remoteApnsKey.value
- remoteKeyId = storage.remoteKeyId.value
- maxBolus = storage.maxBolus.value
- maxCarbs = storage.maxCarbs.value
- maxProtein = storage.maxProtein.value
- maxFat = storage.maxFat.value
- mealWithBolus = storage.mealWithBolus.value
- mealWithFatProtein = storage.mealWithFatProtein.value
- loopDeveloperTeamId = storage.teamId.value ?? ""
- loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
- productionEnvironment = storage.productionEnvironment.value
- setupBindings()
- }
- private func setupBindings() {
- // Basic property bindings
- $remoteType
- .dropFirst()
- .sink { [weak self] in self?.storage.remoteType.value = $0 }
- .store(in: &cancellables)
- $user
- .dropFirst()
- .sink { [weak self] in self?.storage.user.value = $0 }
- .store(in: &cancellables)
- $sharedSecret
- .dropFirst()
- .sink { [weak self] in self?.storage.sharedSecret.value = $0 }
- .store(in: &cancellables)
- $remoteApnsKey
- .dropFirst()
- .sink { [weak self] newValue in
- let apnsService = LoopAPNSService()
- let fixedKey = apnsService.validateAndFixAPNSKey(newValue)
- self?.storage.remoteApnsKey.value = fixedKey
- }
- .store(in: &cancellables)
- $remoteKeyId
- .dropFirst()
- .sink { [weak self] in self?.storage.remoteKeyId.value = $0 }
- .store(in: &cancellables)
- $maxBolus
- .dropFirst()
- .sink { [weak self] in self?.storage.maxBolus.value = $0 }
- .store(in: &cancellables)
- $maxCarbs
- .dropFirst()
- .sink { [weak self] in self?.storage.maxCarbs.value = $0 }
- .store(in: &cancellables)
- $maxProtein
- .dropFirst()
- .sink { [weak self] in self?.storage.maxProtein.value = $0 }
- .store(in: &cancellables)
- $maxFat
- .dropFirst()
- .sink { [weak self] in self?.storage.maxFat.value = $0 }
- .store(in: &cancellables)
- $mealWithBolus
- .dropFirst()
- .sink { [weak self] in self?.storage.mealWithBolus.value = $0 }
- .store(in: &cancellables)
- $mealWithFatProtein
- .dropFirst()
- .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 }
- .store(in: &cancellables)
- // Device type monitoring
- Storage.shared.device.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] newValue in
- self?.isTrioDevice = (newValue == "Trio")
- self?.isLoopDevice = (newValue == "Loop")
- }
- .store(in: &cancellables)
- // Loop APNS bindings
- $loopDeveloperTeamId
- .dropFirst()
- .sink { [weak self] in self?.storage.teamId.value = $0 }
- .store(in: &cancellables)
- $loopAPNSQrCodeURL
- .dropFirst()
- .sink { [weak self] in self?.storage.loopAPNSQrCodeURL.value = $0 }
- .store(in: &cancellables)
- $productionEnvironment
- .dropFirst()
- .sink { [weak self] in self?.storage.productionEnvironment.value = $0 }
- .store(in: &cancellables)
- }
- func handleLoopAPNSQRCodeScanResult(_ result: Result<String, Error>) {
- DispatchQueue.main.async {
- switch result {
- case let .success(code):
- self.loopAPNSQrCodeURL = code
- // Set device type and remote type for Loop APNS
- Storage.shared.device.value = "Loop"
- Storage.shared.remoteType.value = .loopAPNS
- // Update view model properties
- self.remoteType = .loopAPNS
- self.isLoopDevice = true
- self.isTrioDevice = false
- LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(LogRedactor.fingerprint(code))")
- case let .failure(error):
- self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
- }
- self.isShowingLoopAPNSScanner = false
- }
- }
- // MARK: - Public Methods for View Access
- /// Updates the view model properties from storage (accessible from view)
- func updateViewModelFromStorage() {
- let storage = Storage.shared
- remoteType = storage.remoteType.value
- user = storage.user.value
- sharedSecret = storage.sharedSecret.value
- remoteApnsKey = storage.remoteApnsKey.value
- remoteKeyId = storage.remoteKeyId.value
- maxBolus = storage.maxBolus.value
- maxCarbs = storage.maxCarbs.value
- maxProtein = storage.maxProtein.value
- maxFat = storage.maxFat.value
- mealWithBolus = storage.mealWithBolus.value
- mealWithFatProtein = storage.mealWithFatProtein.value
- loopDeveloperTeamId = storage.teamId.value ?? ""
- loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
- productionEnvironment = storage.productionEnvironment.value
- // Update device-related properties
- isTrioDevice = (storage.device.value == "Trio")
- isLoopDevice = (storage.device.value == "Loop")
- }
- // MARK: - Diagnostics
- func runDiagnostics() {
- diagnostics = RemoteDiagnostics(status: .running)
- guard !storage.url.value.isEmpty else {
- diagnostics = RemoteDiagnostics(status: .ok)
- return
- }
- let parameters: [String: String] = [
- "count": "\(diagnosticsHistoryCap)",
- ]
- NightscoutUtils.executeRequest(
- eventType: .profile,
- parameters: parameters
- ) { [weak self] (result: Result<[NSProfile], Error>) in
- guard let self = self else { return }
- switch result {
- case let .success(history):
- let evaluated = self.evaluateDiagnostics(history: history)
- DispatchQueue.main.async {
- self.diagnostics = evaluated
- LogManager.shared.log(
- category: .nightscout,
- message: "Remote diagnostics evaluated: records=\(history.count) bundleMismatch=\(evaluated.bundleMismatch != nil) bouncingTokens=\(evaluated.bouncingTokens != nil) futureStartDate=\(evaluated.futureStartDate != nil)"
- )
- }
- case let .failure(error):
- DispatchQueue.main.async {
- self.diagnostics = RemoteDiagnostics(status: .failed(error.localizedDescription))
- }
- }
- }
- }
- private func evaluateDiagnostics(history: [NSProfile]) -> RemoteDiagnostics {
- var result = RemoteDiagnostics(status: .ok)
- let device = storage.device.value
- if let current = history.first, !device.isEmpty {
- let topLevel = current.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? ""
- let nested = current.loopSettings?.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? ""
- if device == "Loop", nested.isEmpty, !topLevel.isEmpty {
- result.bundleMismatch = .init(expectedDevice: "Loop", observedBundleId: topLevel)
- } else if device == "Trio", topLevel.isEmpty, !nested.isEmpty {
- result.bundleMismatch = .init(expectedDevice: "Trio", observedBundleId: nested)
- }
- }
- let chronological = history.sorted { lhs, rhs in
- profileTimestamp(lhs) < profileTimestamp(rhs)
- }
- struct CompressedEntry {
- let token: String
- let when: Date
- let bundle: String?
- }
- var compressed: [CompressedEntry] = []
- for record in chronological {
- guard let token = record.deviceToken ?? record.loopSettings?.deviceToken,
- !token.isEmpty else { continue }
- if compressed.last?.token != token {
- compressed.append(
- CompressedEntry(
- token: token,
- when: profileTimestamp(record),
- bundle: record.bundleIdentifier ?? record.loopSettings?.bundleIdentifier
- )
- )
- }
- }
- let distinctTokens = Set(compressed.map { $0.token })
- if compressed.count > distinctTokens.count {
- var shifts: [RemoteDiagnostics.TokenShift] = []
- for pair in zip(compressed, compressed.dropFirst()) {
- shifts.append(
- RemoteDiagnostics.TokenShift(
- when: pair.1.when,
- fromToken: pair.0.token,
- toToken: pair.1.token,
- bundleIdentifier: pair.1.bundle
- )
- )
- }
- result.bouncingTokens = .init(
- distinctCount: distinctTokens.count,
- recordsScanned: history.count,
- shifts: shifts
- )
- }
- let dates = history.compactMap { $0.startDate.flatMap(NightscoutUtils.parseDate) }
- if let maxDate = dates.max(), maxDate > Date().addingTimeInterval(futureStartDateTolerance) {
- result.futureStartDate = .init(startDate: maxDate)
- }
- return result
- }
- private func profileTimestamp(_ profile: NSProfile) -> Date {
- if let s = profile.startDate, let d = NightscoutUtils.parseDate(s) { return d }
- if let s = profile.createdAt, let d = NightscoutUtils.parseDate(s) { return d }
- return .distantPast
- }
- }
|