LiveActivityBridge.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import ActivityKit
  2. import Foundation
  3. import Swinject
  4. import UIKit
  5. extension LiveActivityAttributes.ContentState {
  6. static func formatGlucose(_ value: Int, mmol: Bool, forceSign: Bool) -> String {
  7. let formatter = NumberFormatter()
  8. formatter.numberStyle = .decimal
  9. formatter.maximumFractionDigits = 0
  10. if mmol {
  11. formatter.minimumFractionDigits = 1
  12. formatter.maximumFractionDigits = 1
  13. }
  14. if forceSign {
  15. formatter.positivePrefix = formatter.plusSign
  16. }
  17. formatter.roundingMode = .halfUp
  18. return formatter
  19. .string(from: mmol ? value.asMmolL as NSNumber : NSNumber(value: value))!
  20. }
  21. init?(
  22. new bg: BloodGlucose,
  23. prev: BloodGlucose?,
  24. mmol: Bool,
  25. chart: [GlucoseStored],
  26. settings: FreeAPSSettings,
  27. suggestion: Suggestion
  28. ) {
  29. guard let glucose = bg.glucose else {
  30. return nil
  31. }
  32. let formattedBG = Self.formatGlucose(glucose, mmol: mmol, forceSign: false)
  33. var rotationDegrees: Double = 0.0
  34. switch bg.direction {
  35. case .doubleUp,
  36. .singleUp,
  37. .tripleUp:
  38. rotationDegrees = -90
  39. case .fortyFiveUp:
  40. rotationDegrees = -45
  41. case .flat:
  42. rotationDegrees = 0
  43. case .fortyFiveDown:
  44. rotationDegrees = 45
  45. case .doubleDown,
  46. .singleDown,
  47. .tripleDown:
  48. rotationDegrees = 90
  49. case .notComputable,
  50. Optional.none,
  51. .rateOutOfRange,
  52. .some(.none):
  53. rotationDegrees = 0
  54. }
  55. let trendString = bg.direction?.symbol
  56. let change = prev?.glucose.map({
  57. Self.formatGlucose(glucose - $0, mmol: mmol, forceSign: true)
  58. }) ?? ""
  59. let chartBG = chart.map(\.glucose)
  60. let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
  61. let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
  62. let chartDate = chart.map(\.date)
  63. /// glucose limits from UI settings, not from notifications settings
  64. let highGlucose = settings.high / Decimal(conversionFactor)
  65. let lowGlucose = settings.low / Decimal(conversionFactor)
  66. let cob = suggestion.cob ?? 0
  67. let iob = suggestion.iob ?? 0
  68. let lockScreenView = settings.lockScreenView.displayName
  69. let unit = settings.units == .mmolL ? " mmol/L" : " mg/dL"
  70. self.init(
  71. bg: formattedBG,
  72. direction: trendString,
  73. change: change,
  74. date: bg.dateString,
  75. chart: convertedChartBG,
  76. chartDate: chartDate,
  77. rotationDegrees: rotationDegrees,
  78. highGlucose: Double(highGlucose),
  79. lowGlucose: Double(lowGlucose),
  80. cob: cob,
  81. iob: iob,
  82. lockScreenView: lockScreenView,
  83. unit: unit
  84. )
  85. }
  86. }
  87. @available(iOS 16.2, *) private struct ActiveActivity {
  88. let activity: Activity<LiveActivityAttributes>
  89. let startDate: Date
  90. func needsRecreation() -> Bool {
  91. switch activity.activityState {
  92. case .dismissed,
  93. .ended,
  94. .stale:
  95. return true
  96. case .active: break
  97. @unknown default:
  98. return true
  99. }
  100. return -startDate.timeIntervalSinceNow >
  101. TimeInterval(60 * 60)
  102. }
  103. }
  104. @available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject {
  105. @Injected() private var settingsManager: SettingsManager!
  106. @Injected() private var glucoseStorage: GlucoseStorage!
  107. @Injected() private var broadcaster: Broadcaster!
  108. @Injected() private var storage: FileStorage!
  109. private let activityAuthorizationInfo = ActivityAuthorizationInfo()
  110. @Published private(set) var systemEnabled: Bool
  111. private var settings: FreeAPSSettings {
  112. settingsManager.settings
  113. }
  114. var suggestion: Suggestion? {
  115. storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self)
  116. }
  117. private var currentActivity: ActiveActivity?
  118. private var latestGlucose: BloodGlucose?
  119. init(resolver: Resolver) {
  120. systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
  121. injectServices(resolver)
  122. broadcaster.register(GlucoseObserver.self, observer: self)
  123. Foundation.NotificationCenter.default.addObserver(
  124. forName: UIApplication.didEnterBackgroundNotification,
  125. object: nil,
  126. queue: nil
  127. ) { _ in
  128. self.forceActivityUpdate()
  129. }
  130. Foundation.NotificationCenter.default.addObserver(
  131. forName: UIApplication.didBecomeActiveNotification,
  132. object: nil,
  133. queue: nil
  134. ) { _ in
  135. self.forceActivityUpdate()
  136. }
  137. monitorForLiveActivityAuthorizationChanges()
  138. }
  139. private func monitorForLiveActivityAuthorizationChanges() {
  140. Task {
  141. for await activityState in activityAuthorizationInfo.activityEnablementUpdates {
  142. if activityState != systemEnabled {
  143. await MainActor.run {
  144. systemEnabled = activityState
  145. }
  146. }
  147. }
  148. }
  149. }
  150. /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
  151. /// Ends existing live activities if live activities are not enabled in settings
  152. private func forceActivityUpdate() {
  153. // just before app resigns active, show a new activity
  154. // only do this if there is no current activity or the current activity is older than 1h
  155. if settings.useLiveActivity {
  156. if currentActivity?.needsRecreation() ?? true
  157. {
  158. glucoseDidUpdate(glucoseStorage.recent())
  159. }
  160. } else {
  161. Task {
  162. await self.endActivity()
  163. }
  164. }
  165. }
  166. /// attempts to present this live activity state, creating a new activity if none exists yet
  167. @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
  168. // hide duplicate/unknown activities
  169. for unknownActivity in Activity<LiveActivityAttributes>.activities
  170. .filter({ self.currentActivity?.activity.id != $0.id })
  171. {
  172. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  173. }
  174. if let currentActivity {
  175. if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
  176. // activity is no longer visible or old. End it and try to push the update again
  177. await endActivity()
  178. await pushUpdate(state)
  179. } else {
  180. let content = ActivityContent(
  181. state: state,
  182. staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60))
  183. )
  184. await currentActivity.activity.update(content)
  185. }
  186. } else {
  187. do {
  188. // always push a non-stale content as the first update
  189. // pushing a stale content as the frst content results in the activity not being shown at all
  190. // 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
  191. let nonStale = ActivityContent(
  192. state: LiveActivityAttributes.ContentState(
  193. bg: "--",
  194. direction: nil,
  195. change: "--",
  196. date: Date.now,
  197. chart: [],
  198. chartDate: [],
  199. rotationDegrees: 0,
  200. highGlucose: Double(180),
  201. lowGlucose: Double(70),
  202. cob: 0,
  203. iob: 0,
  204. lockScreenView: "Simple",
  205. unit: "--"
  206. ),
  207. staleDate: Date.now.addingTimeInterval(60)
  208. )
  209. let activity = try Activity.request(
  210. attributes: LiveActivityAttributes(startDate: Date.now),
  211. content: nonStale,
  212. pushType: nil
  213. )
  214. currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
  215. // then show the actual content
  216. await pushUpdate(state)
  217. } catch {
  218. print("activity creation error: \(error)")
  219. }
  220. }
  221. }
  222. /// ends all live activities immediateny
  223. private func endActivity() async {
  224. if let currentActivity {
  225. await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
  226. self.currentActivity = nil
  227. }
  228. // end any other activities
  229. for unknownActivity in Activity<LiveActivityAttributes>.activities {
  230. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  231. }
  232. }
  233. }
  234. @available(iOS 16.2, *)
  235. extension LiveActivityBridge: GlucoseObserver {
  236. func glucoseDidUpdate(_ glucose: [BloodGlucose]) {
  237. guard settings.useLiveActivity else {
  238. if currentActivity != nil {
  239. Task {
  240. await self.endActivity()
  241. }
  242. }
  243. return
  244. }
  245. // backfill latest glucose if contained in this update
  246. if glucose.count > 1 {
  247. latestGlucose = glucose[glucose.count - 2]
  248. }
  249. defer {
  250. self.latestGlucose = glucose.last
  251. }
  252. // fetch glucose for the last 6 hours for the LA chart from Core Data
  253. let fetchedGlucose = fetchGlucose()
  254. guard let bg = glucose.last else {
  255. return
  256. }
  257. if let suggestion = suggestion {
  258. let content = LiveActivityAttributes.ContentState(
  259. new: bg,
  260. prev: latestGlucose,
  261. mmol: settings.units == .mmolL,
  262. chart: fetchedGlucose,
  263. settings: settings,
  264. suggestion: suggestion
  265. )
  266. if let content = content {
  267. Task {
  268. await self.pushUpdate(content)
  269. }
  270. }
  271. }
  272. }
  273. private func fetchGlucose() -> [GlucoseStored] {
  274. let context = CoreDataStack.shared.persistentContainer.viewContext
  275. do {
  276. let fetchedGlucose = try context.fetch(GlucoseStored.fetch(NSPredicate.predicateForSixHoursAgo, ascending: false))
  277. debugPrint("LA Bridge: \(CoreDataStack.identifier) \(DebuggingIdentifiers.failed) failed to fetch glucose")
  278. return fetchedGlucose
  279. } catch {
  280. debugPrint("LA Bridge: \(CoreDataStack.identifier) \(DebuggingIdentifiers.failed) failed to fetch glucose")
  281. return []
  282. }
  283. }
  284. }