LiveActivityBridge.swift 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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,
  30. bg.dateString.timeIntervalSinceNow > -TimeInterval(minutes: 6)
  31. else {
  32. return nil
  33. }
  34. let formattedBG = Self.formatGlucose(glucose, mmol: mmol, forceSign: false)
  35. let trendString: String?
  36. var rotationDegrees: Double = 0.0
  37. switch bg.direction {
  38. case .doubleUp,
  39. .singleUp,
  40. .tripleUp:
  41. trendString = "arrow.up"
  42. rotationDegrees = -90
  43. case .fortyFiveUp:
  44. trendString = "arrow.up.right"
  45. rotationDegrees = -45
  46. case .flat:
  47. trendString = "arrow.right"
  48. rotationDegrees = 0
  49. case .fortyFiveDown:
  50. trendString = "arrow.down.right"
  51. rotationDegrees = 45
  52. case .doubleDown,
  53. .singleDown,
  54. .tripleDown:
  55. trendString = "arrow.down"
  56. rotationDegrees = 90
  57. case .notComputable,
  58. Optional.none,
  59. .rateOutOfRange,
  60. .some(.none):
  61. trendString = nil
  62. rotationDegrees = 0
  63. }
  64. let change = prev?.glucose.map({
  65. Self.formatGlucose(glucose - $0, mmol: mmol, forceSign: true)
  66. }) ?? ""
  67. let chartBG = chart.map(\.glucose)
  68. let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
  69. let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
  70. let chartDate = chart.map(\.date)
  71. /// glucose limits from settings
  72. let highGlucose = settings.highGlucose
  73. let lowGlucose = settings.lowGlucose
  74. let cob = suggestion.cob ?? 0
  75. let iob = suggestion.iob ?? 0
  76. let lockScreenView = settings.lockScreenView.displayName
  77. self.init(
  78. bg: formattedBG,
  79. trendSystemImage: trendString,
  80. change: change,
  81. date: bg.dateString,
  82. chart: convertedChartBG,
  83. chartDate: chartDate,
  84. rotationDegrees: rotationDegrees,
  85. highGlucose: Double(highGlucose),
  86. lowGlucose: Double(lowGlucose),
  87. cob: cob,
  88. iob: iob,
  89. lockScreenView: lockScreenView
  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. return true
  101. case .active,
  102. .stale: 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 {
  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 var settings: FreeAPSSettings {
  116. settingsManager.settings
  117. }
  118. var suggestion: Suggestion? {
  119. storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self)
  120. }
  121. private var currentActivity: ActiveActivity?
  122. private var latestGlucose: BloodGlucose?
  123. init(resolver: Resolver) {
  124. injectServices(resolver)
  125. broadcaster.register(GlucoseObserver.self, observer: self)
  126. Foundation.NotificationCenter.default.addObserver(
  127. forName: UIApplication.didEnterBackgroundNotification,
  128. object: nil,
  129. queue: nil
  130. ) { _ in
  131. self.forceActivityUpdate()
  132. }
  133. Foundation.NotificationCenter.default.addObserver(
  134. forName: UIApplication.didBecomeActiveNotification,
  135. object: nil,
  136. queue: nil
  137. ) { _ in
  138. self.forceActivityUpdate()
  139. }
  140. }
  141. /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
  142. /// Ends existing live activities if live activities are not enabled in settings
  143. private func forceActivityUpdate() {
  144. // just before app resigns active, show a new activity
  145. // only do this if there is no current activity or the current activity is older than 1h
  146. if settings.useLiveActivity {
  147. if currentActivity?.needsRecreation() ?? true
  148. {
  149. glucoseDidUpdate(glucoseStorage.recent())
  150. }
  151. } else {
  152. Task {
  153. await self.endActivity()
  154. }
  155. }
  156. }
  157. /// attempts to present this live activity state, creating a new activity if none exists yet
  158. @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
  159. // hide duplicate/unknown activities
  160. for unknownActivity in Activity<LiveActivityAttributes>.activities
  161. .filter({ self.currentActivity?.activity.id != $0.id })
  162. {
  163. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  164. }
  165. let content = ActivityContent(state: state, staleDate: state.date.addingTimeInterval(TimeInterval(6 * 60)))
  166. if let currentActivity {
  167. if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
  168. // activity is no longer visible or old. End it and try to push the update again
  169. await endActivity()
  170. await pushUpdate(state)
  171. } else {
  172. await currentActivity.activity.update(content)
  173. }
  174. } else {
  175. do {
  176. let activity = try Activity.request(
  177. attributes: LiveActivityAttributes(startDate: Date.now),
  178. content: content,
  179. pushType: nil
  180. )
  181. currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
  182. } catch {
  183. print("activity creation error: \(error)")
  184. }
  185. }
  186. }
  187. /// ends all live activities immediateny
  188. private func endActivity() async {
  189. if let currentActivity {
  190. await currentActivity.activity.end(nil, dismissalPolicy: ActivityUIDismissalPolicy.immediate)
  191. self.currentActivity = nil
  192. }
  193. // end any other activities
  194. for unknownActivity in Activity<LiveActivityAttributes>.activities {
  195. await unknownActivity.end(nil, dismissalPolicy: .immediate)
  196. }
  197. }
  198. }
  199. @available(iOS 16.2, *)
  200. extension LiveActivityBridge: GlucoseObserver {
  201. func glucoseDidUpdate(_ glucose: [BloodGlucose]) {
  202. // backfill latest glucose if contained in this update
  203. if glucose.count > 1 {
  204. latestGlucose = glucose[glucose.count - 2]
  205. }
  206. defer {
  207. self.latestGlucose = glucose.last
  208. }
  209. // fetch glucose for chart from Core Data
  210. let coreDataStorage = CoreDataStorage()
  211. let sixHoursAgo = Calendar.current.date(byAdding: .hour, value: -6, to: Date()) ?? Date()
  212. let fetchGlucose = coreDataStorage.fetchGlucose(interval: sixHoursAgo as NSDate)
  213. guard let bg = glucose.last else {
  214. return
  215. }
  216. if let suggestion = suggestion {
  217. let content = LiveActivityAttributes.ContentState(
  218. new: bg,
  219. prev: latestGlucose,
  220. mmol: settings.units == .mmolL,
  221. chart: fetchGlucose,
  222. settings: settings,
  223. suggestion: suggestion
  224. )
  225. if let content = content {
  226. Task {
  227. await self.pushUpdate(content)
  228. }
  229. }
  230. }
  231. }
  232. }