LiveActivityBridge.swift 10 KB

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