LiveActivityBridge.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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: [Readings],
  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
  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. self.init(
  70. bg: formattedBG,
  71. direction: trendString,
  72. change: change,
  73. date: bg.dateString,
  74. chart: convertedChartBG,
  75. chartDate: chartDate,
  76. rotationDegrees: rotationDegrees,
  77. highGlucose: Double(highGlucose),
  78. lowGlucose: Double(lowGlucose),
  79. cob: cob,
  80. iob: iob,
  81. lockScreenView: lockScreenView
  82. )
  83. }
  84. }
  85. @available(iOS 16.2, *) private struct ActiveActivity {
  86. let activity: Activity<LiveActivityAttributes>
  87. let startDate: Date
  88. func needsRecreation() -> Bool {
  89. switch activity.activityState {
  90. case .dismissed,
  91. .ended,
  92. .stale:
  93. return true
  94. case .active: break
  95. @unknown default:
  96. return true
  97. }
  98. return -startDate.timeIntervalSinceNow >
  99. TimeInterval(60 * 60)
  100. }
  101. }
  102. @available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject {
  103. @Injected() private var settingsManager: SettingsManager!
  104. @Injected() private var glucoseStorage: GlucoseStorage!
  105. @Injected() private var broadcaster: Broadcaster!
  106. @Injected() private var storage: FileStorage!
  107. private let activityAuthorizationInfo = ActivityAuthorizationInfo()
  108. @Published private(set) var systemEnabled: Bool
  109. private var settings: FreeAPSSettings {
  110. settingsManager.settings
  111. }
  112. var suggestion: Suggestion? {
  113. storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self)
  114. }
  115. private var currentActivity: ActiveActivity?
  116. private var latestGlucose: BloodGlucose?
  117. init(resolver: Resolver) {
  118. systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
  119. injectServices(resolver)
  120. broadcaster.register(GlucoseObserver.self, observer: self)
  121. Foundation.NotificationCenter.default.addObserver(
  122. forName: UIApplication.didEnterBackgroundNotification,
  123. object: nil,
  124. queue: nil
  125. ) { _ in
  126. self.forceActivityUpdate()
  127. }
  128. Foundation.NotificationCenter.default.addObserver(
  129. forName: UIApplication.didBecomeActiveNotification,
  130. object: nil,
  131. queue: nil
  132. ) { _ in
  133. self.forceActivityUpdate()
  134. }
  135. monitorForLiveActivityAuthorizationChanges()
  136. }
  137. private func monitorForLiveActivityAuthorizationChanges() {
  138. Task {
  139. for await activityState in activityAuthorizationInfo.activityEnablementUpdates {
  140. if activityState != systemEnabled {
  141. await MainActor.run {
  142. systemEnabled = activityState
  143. }
  144. }
  145. }
  146. }
  147. }
  148. /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
  149. /// Ends existing live activities if live activities are not enabled in settings
  150. private func forceActivityUpdate() {
  151. // just before app resigns active, show a new activity
  152. // only do this if there is no current activity or the current activity is older than 1h
  153. if settings.useLiveActivity {
  154. if currentActivity?.needsRecreation() ?? true
  155. {
  156. glucoseDidUpdate(glucoseStorage.recent())
  157. }
  158. } else {
  159. Task {
  160. await self.endActivity()
  161. }
  162. }
  163. }
  164. /// attempts to present this live activity state, creating a new activity if none exists yet
  165. @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
  166. // hide duplicate/unknown activities
  167. for unknownActivity in Activity<LiveActivityAttributes>.activities
  168. .filter({ self.currentActivity?.activity.id != $0.id })
  169. {
  170. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  171. }
  172. if let currentActivity {
  173. if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
  174. // activity is no longer visible or old. End it and try to push the update again
  175. await endActivity()
  176. await pushUpdate(state)
  177. } else {
  178. let content = ActivityContent(
  179. state: state,
  180. staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60))
  181. )
  182. await currentActivity.activity.update(content)
  183. }
  184. } else {
  185. do {
  186. // always push a non-stale content as the first update
  187. // pushing a stale content as the frst content results in the activity not being shown at all
  188. // 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
  189. let nonStale = ActivityContent(
  190. state: LiveActivityAttributes.ContentState(
  191. bg: "--",
  192. direction: nil,
  193. change: "--",
  194. date: Date.now,
  195. chart: [],
  196. chartDate: [],
  197. rotationDegrees: 0,
  198. highGlucose: Double(180),
  199. lowGlucose: Double(70),
  200. cob: 0,
  201. iob: 0,
  202. lockScreenView: "Simple"
  203. ),
  204. staleDate: Date.now.addingTimeInterval(60)
  205. )
  206. let activity = try Activity.request(
  207. attributes: LiveActivityAttributes(startDate: Date.now),
  208. content: nonStale,
  209. pushType: nil
  210. )
  211. currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
  212. // then show the actual content
  213. await pushUpdate(state)
  214. } catch {
  215. print("activity creation error: \(error)")
  216. }
  217. }
  218. }
  219. /// ends all live activities immediateny
  220. private func endActivity() async {
  221. if let currentActivity {
  222. await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
  223. self.currentActivity = nil
  224. }
  225. // end any other activities
  226. for unknownActivity in Activity<LiveActivityAttributes>.activities {
  227. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  228. }
  229. }
  230. }
  231. @available(iOS 16.2, *)
  232. extension LiveActivityBridge: GlucoseObserver {
  233. func glucoseDidUpdate(_ glucose: [BloodGlucose]) {
  234. guard settings.useLiveActivity else {
  235. if currentActivity != nil {
  236. Task {
  237. await self.endActivity()
  238. }
  239. }
  240. return
  241. }
  242. // backfill latest glucose if contained in this update
  243. if glucose.count > 1 {
  244. latestGlucose = glucose[glucose.count - 2]
  245. }
  246. defer {
  247. self.latestGlucose = glucose.last
  248. }
  249. // fetch glucose for chart from Core Data
  250. let coreDataStorage = CoreDataStorage()
  251. let sixHoursAgo = Calendar.current.date(byAdding: .hour, value: -6, to: Date()) ?? Date()
  252. let fetchGlucose = coreDataStorage.fetchGlucose(interval: sixHoursAgo as NSDate)
  253. guard let bg = glucose.last else {
  254. return
  255. }
  256. if let suggestion = suggestion {
  257. let content = LiveActivityAttributes.ContentState(
  258. new: bg,
  259. prev: latestGlucose,
  260. mmol: settings.units == .mmolL,
  261. chart: fetchGlucose,
  262. settings: settings,
  263. suggestion: suggestion
  264. )
  265. if let content = content {
  266. Task {
  267. await self.pushUpdate(content)
  268. }
  269. }
  270. }
  271. }
  272. }