LiveActivityBridge.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import ActivityKit
  2. import CoreData
  3. import Foundation
  4. import Swinject
  5. import UIKit
  6. extension LiveActivityAttributes.ContentState {
  7. static func formatGlucose(_ value: Int, mmol: Bool, forceSign: Bool) -> String {
  8. let formatter = NumberFormatter()
  9. formatter.numberStyle = .decimal
  10. formatter.maximumFractionDigits = 0
  11. if mmol {
  12. formatter.minimumFractionDigits = 1
  13. formatter.maximumFractionDigits = 1
  14. }
  15. if forceSign {
  16. formatter.positivePrefix = formatter.plusSign
  17. }
  18. formatter.roundingMode = .halfUp
  19. return formatter
  20. .string(from: mmol ? value.asMmolL as NSNumber : NSNumber(value: value))!
  21. }
  22. static func calculateChange(chart: [GlucoseStored]) -> String {
  23. guard chart.count > 2 else { return "" }
  24. let lastGlucose = chart.first?.glucose ?? 0
  25. let secondLastGlucose = chart.dropFirst().first?.glucose ?? 0
  26. let delta = lastGlucose - secondLastGlucose
  27. let deltaAsDecimal = Decimal(delta)
  28. let formatter = NumberFormatter()
  29. formatter.numberStyle = .decimal
  30. formatter.maximumFractionDigits = 1
  31. formatter.positivePrefix = " +"
  32. formatter.negativePrefix = " -"
  33. return formatter.string(from: deltaAsDecimal as NSNumber) ?? "--"
  34. }
  35. init?(
  36. new bg: GlucoseStored,
  37. prev _: GlucoseStored?,
  38. mmol: Bool,
  39. chart: [GlucoseStored],
  40. settings: FreeAPSSettings,
  41. determination: OrefDetermination?
  42. ) {
  43. let glucose = bg.glucose
  44. let formattedBG = Self.formatGlucose(Int(glucose), mmol: mmol, forceSign: false)
  45. var rotationDegrees: Double = 0.0
  46. switch bg.direction {
  47. case "DoubleUp",
  48. "SingleUp",
  49. "TripleUp":
  50. rotationDegrees = -90
  51. case "FortyFiveUp":
  52. rotationDegrees = -45
  53. case "Flat":
  54. rotationDegrees = 0
  55. case "FortyFiveDown":
  56. rotationDegrees = 45
  57. case "DoubleDown",
  58. "SingleDown",
  59. "TripleDown":
  60. rotationDegrees = 90
  61. case "NONE",
  62. "NOT COMPUTABLE",
  63. "RATE OUT OF RANGE":
  64. rotationDegrees = 0
  65. default:
  66. rotationDegrees = 0
  67. }
  68. let trendString = bg.direction?.symbol as? String
  69. let change = Self.calculateChange(chart: chart)
  70. let chartBG = chart.map(\.glucose)
  71. let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
  72. let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
  73. let chartDate = chart.map(\.date)
  74. /// glucose limits from UI settings, not from notifications settings
  75. let highGlucose = settings.high / Decimal(conversionFactor)
  76. let lowGlucose = settings.low / Decimal(conversionFactor)
  77. let cob = determination?.cob ?? 0
  78. let iob = determination?.iob ?? 0
  79. let lockScreenView = settings.lockScreenView.displayName
  80. let unit = settings.units == .mmolL ? " mmol/L" : " mg/dL"
  81. self.init(
  82. bg: formattedBG,
  83. direction: trendString,
  84. change: change,
  85. date: bg.date ?? Date(),
  86. chart: convertedChartBG,
  87. chartDate: chartDate,
  88. rotationDegrees: rotationDegrees,
  89. highGlucose: Double(highGlucose),
  90. lowGlucose: Double(lowGlucose),
  91. cob: Decimal(cob),
  92. iob: iob as Decimal,
  93. lockScreenView: lockScreenView,
  94. unit: unit
  95. )
  96. }
  97. }
  98. @available(iOS 16.2, *) private struct ActiveActivity {
  99. let activity: Activity<LiveActivityAttributes>
  100. let startDate: Date
  101. func needsRecreation() -> Bool {
  102. switch activity.activityState {
  103. case .dismissed,
  104. .ended,
  105. .stale:
  106. return true
  107. case .active: break
  108. @unknown default:
  109. return true
  110. }
  111. return -startDate.timeIntervalSinceNow >
  112. TimeInterval(60 * 60)
  113. }
  114. }
  115. @available(iOS 16.2, *) final class LiveActivityBridge: NSObject, Injectable, ObservableObject,
  116. NSFetchedResultsControllerDelegate
  117. {
  118. @Injected() private var settingsManager: SettingsManager!
  119. @Injected() private var broadcaster: Broadcaster!
  120. @Injected() private var storage: FileStorage!
  121. private let activityAuthorizationInfo = ActivityAuthorizationInfo()
  122. @Published private(set) var systemEnabled: Bool
  123. private var settings: FreeAPSSettings {
  124. settingsManager.settings
  125. }
  126. private var determination: OrefDetermination?
  127. private var currentActivity: ActiveActivity?
  128. private var latestGlucose: GlucoseStored?
  129. private var fetchedResultsController: NSFetchedResultsController<GlucoseStored>?
  130. init(resolver: Resolver) {
  131. systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
  132. super.init()
  133. injectServices(resolver)
  134. setupNotifications()
  135. monitorForLiveActivityAuthorizationChanges()
  136. initializeFetchedResultsController()
  137. }
  138. private func setupNotifications() {
  139. Foundation.NotificationCenter.default.addObserver(
  140. forName: UIApplication.didEnterBackgroundNotification,
  141. object: nil,
  142. queue: nil
  143. ) { [weak self] _ in
  144. self?.forceActivityUpdate()
  145. }
  146. Foundation.NotificationCenter.default.addObserver(
  147. forName: UIApplication.didBecomeActiveNotification,
  148. object: nil,
  149. queue: nil
  150. ) { [weak self] _ in
  151. self?.forceActivityUpdate()
  152. }
  153. }
  154. private func initializeFetchedResultsController() {
  155. let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
  156. fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
  157. fetchRequest.fetchLimit = 72
  158. fetchRequest.predicate = NSPredicate.predicateForSixHoursAgo
  159. fetchedResultsController = NSFetchedResultsController(
  160. fetchRequest: fetchRequest,
  161. managedObjectContext: CoreDataStack.shared.persistentContainer.viewContext,
  162. sectionNameKeyPath: nil,
  163. cacheName: nil
  164. )
  165. fetchedResultsController?.delegate = self
  166. do {
  167. try fetchedResultsController?.performFetch()
  168. debugPrint(
  169. "LA Bridge: \(#function) \(CoreDataStack.identifier) \(DebuggingIdentifiers.succeeded) fetched glucose"
  170. )
  171. } catch {
  172. debugPrint(
  173. "LA Bridge: \(#function) \(CoreDataStack.identifier) \(DebuggingIdentifiers.failed) failed to fetch glucose"
  174. ) }
  175. }
  176. private func monitorForLiveActivityAuthorizationChanges() {
  177. Task {
  178. for await activityState in activityAuthorizationInfo.activityEnablementUpdates {
  179. if activityState != systemEnabled {
  180. await MainActor.run {
  181. systemEnabled = activityState
  182. }
  183. }
  184. }
  185. }
  186. }
  187. private func fetchDetermination() -> OrefDetermination? {
  188. let context = CoreDataStack.shared.persistentContainer.viewContext
  189. do {
  190. let determinations = try context.fetch(OrefDetermination.fetch(NSPredicate.enactedDetermination))
  191. debugPrint("LA Bridge: \(#function) \(DebuggingIdentifiers.succeeded) fetched determinations")
  192. if let latestDetermination = determinations.first {
  193. return latestDetermination
  194. }
  195. return nil
  196. } catch {
  197. debugPrint("LA Bridge: \(#function) \(DebuggingIdentifiers.failed) failed to fetch determinaions")
  198. return nil
  199. }
  200. }
  201. /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
  202. /// Ends existing live activities if live activities are not enabled in settings
  203. private func forceActivityUpdate() {
  204. // just before app resigns active, show a new activity
  205. // only do this if there is no current activity or the current activity is older than 1h
  206. if settings.useLiveActivity {
  207. if currentActivity?.needsRecreation() ?? true
  208. {
  209. if let glucoseRecords = fetchedResultsController?.fetchedObjects as? [GlucoseStored] {
  210. glucoseDidUpdate(glucoseRecords)
  211. }
  212. }
  213. } else {
  214. Task {
  215. await self.endActivity()
  216. }
  217. }
  218. }
  219. /// attempts to present this live activity state, creating a new activity if none exists yet
  220. @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
  221. // hide duplicate/unknown activities
  222. for unknownActivity in Activity<LiveActivityAttributes>.activities
  223. .filter({ self.currentActivity?.activity.id != $0.id })
  224. {
  225. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  226. }
  227. if let currentActivity {
  228. if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
  229. // activity is no longer visible or old. End it and try to push the update again
  230. await endActivity()
  231. await pushUpdate(state)
  232. } else {
  233. let content = ActivityContent(
  234. state: state,
  235. staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60))
  236. )
  237. await currentActivity.activity.update(content)
  238. }
  239. } else {
  240. do {
  241. // always push a non-stale content as the first update
  242. // pushing a stale content as the frst content results in the activity not being shown at all
  243. // we want it shown though even if it is iniially stale, as we expect new BG readings to become available soon, which should then be displayed
  244. let nonStale = ActivityContent(
  245. state: LiveActivityAttributes.ContentState(
  246. bg: "--",
  247. direction: nil,
  248. change: "--",
  249. date: Date.now,
  250. chart: [],
  251. chartDate: [],
  252. rotationDegrees: 0,
  253. highGlucose: Double(180),
  254. lowGlucose: Double(70),
  255. cob: 0,
  256. iob: 0,
  257. lockScreenView: "Simple",
  258. unit: "--"
  259. ),
  260. staleDate: Date.now.addingTimeInterval(60)
  261. )
  262. let activity = try Activity.request(
  263. attributes: LiveActivityAttributes(startDate: Date.now),
  264. content: nonStale,
  265. pushType: nil
  266. )
  267. currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
  268. // then show the actual content
  269. await pushUpdate(state)
  270. } catch {
  271. print("activity creation error: \(error)")
  272. }
  273. }
  274. }
  275. /// ends all live activities immediateny
  276. private func endActivity() async {
  277. if let currentActivity {
  278. await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
  279. self.currentActivity = nil
  280. }
  281. // end any other activities
  282. for unknownActivity in Activity<LiveActivityAttributes>.activities {
  283. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  284. }
  285. }
  286. }
  287. @available(iOS 16.2, *)
  288. extension LiveActivityBridge {
  289. func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
  290. guard let glucoseChanges = controller.fetchedObjects as? [GlucoseStored] else {
  291. return
  292. }
  293. glucoseDidUpdate(glucoseChanges)
  294. }
  295. }
  296. @available(iOS 16.2, *)
  297. extension LiveActivityBridge {
  298. func glucoseDidUpdate(_ glucose: [GlucoseStored]) {
  299. guard settings.useLiveActivity else {
  300. if currentActivity != nil {
  301. Task {
  302. await self.endActivity()
  303. }
  304. }
  305. return
  306. }
  307. // backfill latest glucose if contained in this update
  308. if glucose.count > 1 {
  309. latestGlucose = glucose.dropFirst().first
  310. }
  311. defer {
  312. self.latestGlucose = glucose.first
  313. }
  314. guard let bg = glucose.first else {
  315. return
  316. }
  317. determination = fetchDetermination()
  318. if let determination = determination {
  319. let content = LiveActivityAttributes.ContentState(
  320. new: bg,
  321. prev: latestGlucose,
  322. mmol: settings.units == .mmolL,
  323. chart: glucose,
  324. settings: settings,
  325. determination: determination
  326. )
  327. if let content = content {
  328. Task {
  329. await self.pushUpdate(content)
  330. }
  331. }
  332. }
  333. }
  334. }