LiveActivityBridge.swift 8.4 KB

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