RemoteSettingsViewModel.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. // LoopFollow
  2. // RemoteSettingsViewModel.swift
  3. import Combine
  4. import Foundation
  5. import HealthKit
  6. class RemoteSettingsViewModel: ObservableObject {
  7. @Published var remoteType: RemoteType
  8. @Published var user: String
  9. @Published var sharedSecret: String
  10. @Published var remoteApnsKey: String
  11. @Published var remoteKeyId: String
  12. @Published var maxBolus: HKQuantity
  13. @Published var maxCarbs: HKQuantity
  14. @Published var maxProtein: HKQuantity
  15. @Published var maxFat: HKQuantity
  16. @Published var mealWithBolus: Bool
  17. @Published var mealWithFatProtein: Bool
  18. @Published var isTrioDevice: Bool = (Storage.shared.device.value == "Trio")
  19. @Published var isLoopDevice: Bool = (Storage.shared.device.value == "Loop")
  20. // MARK: - Loop APNS Setup Properties
  21. @Published var loopDeveloperTeamId: String
  22. @Published var loopAPNSQrCodeURL: String
  23. @Published var productionEnvironment: Bool
  24. @Published var isShowingLoopAPNSScanner: Bool = false
  25. @Published var loopAPNSErrorMessage: String?
  26. // MARK: - URL/Token Validation Properties
  27. @Published var pendingSettings: RemoteCommandSettings?
  28. @Published var showURLTokenValidation: Bool = false
  29. @Published var validationMessage: String = ""
  30. @Published var shouldPromptForURL: Bool = false
  31. @Published var shouldPromptForToken: Bool = false
  32. // MARK: - Diagnostics
  33. @Published var diagnostics = RemoteDiagnostics()
  34. private let diagnosticsHistoryCap = 1000
  35. private let futureStartDateTolerance: TimeInterval = 60
  36. let loopFollowTeamId: String = BuildDetails.default.teamID ?? "Unknown"
  37. /// Determines if the target app's Team ID is different from this app's build Team ID.
  38. var areTeamIdsDifferent: Bool {
  39. // Get LoopFollow's own Team ID from the build details.
  40. guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty, loopFollowTeamID != "Unknown" else {
  41. return false
  42. }
  43. // The property `loopDeveloperTeamId` holds the value from `Storage.shared.teamId`
  44. let targetTeamId = loopDeveloperTeamId
  45. // Determine if a comparison is needed and perform it.
  46. switch remoteType {
  47. case .trc, .loopAPNS:
  48. guard !targetTeamId.isEmpty else {
  49. return false
  50. }
  51. return loopFollowTeamID != targetTeamId
  52. case .none, .nightscout:
  53. return false
  54. }
  55. }
  56. // MARK: - Computed property for Loop APNS Setup validation
  57. var loopAPNSSetup: Bool {
  58. let hasCredentials: Bool
  59. if areTeamIdsDifferent {
  60. hasCredentials = !remoteKeyId.isEmpty && !remoteApnsKey.isEmpty
  61. } else {
  62. hasCredentials = !Storage.shared.lfKeyId.value.isEmpty && !Storage.shared.lfApnsKey.value.isEmpty
  63. }
  64. return hasCredentials &&
  65. !loopDeveloperTeamId.isEmpty &&
  66. !loopAPNSQrCodeURL.isEmpty &&
  67. !Storage.shared.deviceToken.value.isEmpty &&
  68. !Storage.shared.bundleId.value.isEmpty
  69. }
  70. private var storage = Storage.shared
  71. private var cancellables = Set<AnyCancellable>()
  72. init() {
  73. // Initialize published properties from storage
  74. remoteType = storage.remoteType.value
  75. user = storage.user.value
  76. sharedSecret = storage.sharedSecret.value
  77. remoteApnsKey = storage.remoteApnsKey.value
  78. remoteKeyId = storage.remoteKeyId.value
  79. maxBolus = storage.maxBolus.value
  80. maxCarbs = storage.maxCarbs.value
  81. maxProtein = storage.maxProtein.value
  82. maxFat = storage.maxFat.value
  83. mealWithBolus = storage.mealWithBolus.value
  84. mealWithFatProtein = storage.mealWithFatProtein.value
  85. loopDeveloperTeamId = storage.teamId.value ?? ""
  86. loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
  87. productionEnvironment = storage.productionEnvironment.value
  88. setupBindings()
  89. }
  90. private func setupBindings() {
  91. // Basic property bindings
  92. $remoteType
  93. .dropFirst()
  94. .sink { [weak self] in self?.storage.remoteType.value = $0 }
  95. .store(in: &cancellables)
  96. $user
  97. .dropFirst()
  98. .sink { [weak self] in self?.storage.user.value = $0 }
  99. .store(in: &cancellables)
  100. $sharedSecret
  101. .dropFirst()
  102. .sink { [weak self] in self?.storage.sharedSecret.value = $0 }
  103. .store(in: &cancellables)
  104. $remoteApnsKey
  105. .dropFirst()
  106. .sink { [weak self] newValue in
  107. let apnsService = LoopAPNSService()
  108. let fixedKey = apnsService.validateAndFixAPNSKey(newValue)
  109. self?.storage.remoteApnsKey.value = fixedKey
  110. }
  111. .store(in: &cancellables)
  112. $remoteKeyId
  113. .dropFirst()
  114. .sink { [weak self] in self?.storage.remoteKeyId.value = $0 }
  115. .store(in: &cancellables)
  116. $maxBolus
  117. .dropFirst()
  118. .sink { [weak self] in self?.storage.maxBolus.value = $0 }
  119. .store(in: &cancellables)
  120. $maxCarbs
  121. .dropFirst()
  122. .sink { [weak self] in self?.storage.maxCarbs.value = $0 }
  123. .store(in: &cancellables)
  124. $maxProtein
  125. .dropFirst()
  126. .sink { [weak self] in self?.storage.maxProtein.value = $0 }
  127. .store(in: &cancellables)
  128. $maxFat
  129. .dropFirst()
  130. .sink { [weak self] in self?.storage.maxFat.value = $0 }
  131. .store(in: &cancellables)
  132. $mealWithBolus
  133. .dropFirst()
  134. .sink { [weak self] in self?.storage.mealWithBolus.value = $0 }
  135. .store(in: &cancellables)
  136. $mealWithFatProtein
  137. .dropFirst()
  138. .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 }
  139. .store(in: &cancellables)
  140. // Device type monitoring
  141. Storage.shared.device.$value
  142. .receive(on: DispatchQueue.main)
  143. .sink { [weak self] newValue in
  144. self?.isTrioDevice = (newValue == "Trio")
  145. self?.isLoopDevice = (newValue == "Loop")
  146. }
  147. .store(in: &cancellables)
  148. // Loop APNS bindings
  149. $loopDeveloperTeamId
  150. .dropFirst()
  151. .sink { [weak self] in self?.storage.teamId.value = $0 }
  152. .store(in: &cancellables)
  153. $loopAPNSQrCodeURL
  154. .dropFirst()
  155. .sink { [weak self] in self?.storage.loopAPNSQrCodeURL.value = $0 }
  156. .store(in: &cancellables)
  157. $productionEnvironment
  158. .dropFirst()
  159. .sink { [weak self] in self?.storage.productionEnvironment.value = $0 }
  160. .store(in: &cancellables)
  161. }
  162. func handleLoopAPNSQRCodeScanResult(_ result: Result<String, Error>) {
  163. DispatchQueue.main.async {
  164. switch result {
  165. case let .success(code):
  166. self.loopAPNSQrCodeURL = code
  167. // Set device type and remote type for Loop APNS
  168. Storage.shared.device.value = "Loop"
  169. Storage.shared.remoteType.value = .loopAPNS
  170. // Update view model properties
  171. self.remoteType = .loopAPNS
  172. self.isLoopDevice = true
  173. self.isTrioDevice = false
  174. LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(LogRedactor.fingerprint(code))")
  175. case let .failure(error):
  176. self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
  177. }
  178. self.isShowingLoopAPNSScanner = false
  179. }
  180. }
  181. // MARK: - Public Methods for View Access
  182. /// Updates the view model properties from storage (accessible from view)
  183. func updateViewModelFromStorage() {
  184. let storage = Storage.shared
  185. remoteType = storage.remoteType.value
  186. user = storage.user.value
  187. sharedSecret = storage.sharedSecret.value
  188. remoteApnsKey = storage.remoteApnsKey.value
  189. remoteKeyId = storage.remoteKeyId.value
  190. maxBolus = storage.maxBolus.value
  191. maxCarbs = storage.maxCarbs.value
  192. maxProtein = storage.maxProtein.value
  193. maxFat = storage.maxFat.value
  194. mealWithBolus = storage.mealWithBolus.value
  195. mealWithFatProtein = storage.mealWithFatProtein.value
  196. loopDeveloperTeamId = storage.teamId.value ?? ""
  197. loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value
  198. productionEnvironment = storage.productionEnvironment.value
  199. // Update device-related properties
  200. isTrioDevice = (storage.device.value == "Trio")
  201. isLoopDevice = (storage.device.value == "Loop")
  202. }
  203. // MARK: - Diagnostics
  204. func runDiagnostics() {
  205. diagnostics = RemoteDiagnostics(status: .running)
  206. guard !storage.url.value.isEmpty else {
  207. diagnostics = RemoteDiagnostics(status: .ok)
  208. return
  209. }
  210. let parameters: [String: String] = [
  211. "count": "\(diagnosticsHistoryCap)",
  212. ]
  213. NightscoutUtils.executeRequest(
  214. eventType: .profile,
  215. parameters: parameters
  216. ) { [weak self] (result: Result<[NSProfile], Error>) in
  217. guard let self = self else { return }
  218. switch result {
  219. case let .success(history):
  220. let evaluated = self.evaluateDiagnostics(history: history)
  221. DispatchQueue.main.async {
  222. self.diagnostics = evaluated
  223. LogManager.shared.log(
  224. category: .nightscout,
  225. message: "Remote diagnostics evaluated: records=\(history.count) bundleMismatch=\(evaluated.bundleMismatch != nil) bouncingTokens=\(evaluated.bouncingTokens != nil) futureStartDate=\(evaluated.futureStartDate != nil)"
  226. )
  227. }
  228. case let .failure(error):
  229. DispatchQueue.main.async {
  230. self.diagnostics = RemoteDiagnostics(status: .failed(error.localizedDescription))
  231. }
  232. }
  233. }
  234. }
  235. private func evaluateDiagnostics(history: [NSProfile]) -> RemoteDiagnostics {
  236. var result = RemoteDiagnostics(status: .ok)
  237. let device = storage.device.value
  238. if let current = history.first, !device.isEmpty {
  239. let topLevel = current.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? ""
  240. let nested = current.loopSettings?.bundleIdentifier?.trimmingCharacters(in: .whitespaces) ?? ""
  241. if device == "Loop", nested.isEmpty, !topLevel.isEmpty {
  242. result.bundleMismatch = .init(expectedDevice: "Loop", observedBundleId: topLevel)
  243. } else if device == "Trio", topLevel.isEmpty, !nested.isEmpty {
  244. result.bundleMismatch = .init(expectedDevice: "Trio", observedBundleId: nested)
  245. }
  246. }
  247. let chronological = history.sorted { lhs, rhs in
  248. profileTimestamp(lhs) < profileTimestamp(rhs)
  249. }
  250. struct CompressedEntry {
  251. let token: String
  252. let when: Date
  253. let bundle: String?
  254. }
  255. var compressed: [CompressedEntry] = []
  256. for record in chronological {
  257. guard let token = record.deviceToken ?? record.loopSettings?.deviceToken,
  258. !token.isEmpty else { continue }
  259. if compressed.last?.token != token {
  260. compressed.append(
  261. CompressedEntry(
  262. token: token,
  263. when: profileTimestamp(record),
  264. bundle: record.bundleIdentifier ?? record.loopSettings?.bundleIdentifier
  265. )
  266. )
  267. }
  268. }
  269. let distinctTokens = Set(compressed.map { $0.token })
  270. if compressed.count > distinctTokens.count {
  271. var shifts: [RemoteDiagnostics.TokenShift] = []
  272. for pair in zip(compressed, compressed.dropFirst()) {
  273. shifts.append(
  274. RemoteDiagnostics.TokenShift(
  275. when: pair.1.when,
  276. fromToken: pair.0.token,
  277. toToken: pair.1.token,
  278. bundleIdentifier: pair.1.bundle
  279. )
  280. )
  281. }
  282. result.bouncingTokens = .init(
  283. distinctCount: distinctTokens.count,
  284. recordsScanned: history.count,
  285. shifts: shifts
  286. )
  287. }
  288. let dates = history.compactMap { $0.startDate.flatMap(NightscoutUtils.parseDate) }
  289. if let maxDate = dates.max(), maxDate > Date().addingTimeInterval(futureStartDateTolerance) {
  290. result.futureStartDate = .init(startDate: maxDate)
  291. }
  292. return result
  293. }
  294. private func profileTimestamp(_ profile: NSProfile) -> Date {
  295. if let s = profile.startDate, let d = NightscoutUtils.parseDate(s) { return d }
  296. if let s = profile.createdAt, let d = NightscoutUtils.parseDate(s) { return d }
  297. return .distantPast
  298. }
  299. }