| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- import ActivityKit
- import Combine
- import CoreData
- import Foundation
- import Swinject
- import UIKit
- @available(iOS 16.2, *) private struct ActiveActivity {
- let activity: Activity<LiveActivityAttributes>
- /// Determines if the current activity needs to be recreated.
- ///
- /// - Returns: `true` if the activity is dismissed, ended, stale, or has been active for more than 60 minutes; otherwise,
- /// `false`.
- func needsRecreation() -> Bool {
- switch activity.activityState {
- case .dismissed,
- .ended,
- .stale:
- return true
- case .active:
- break
- @unknown default:
- return true
- }
- return -activity.attributes.startDate.timeIntervalSinceNow > TimeInterval(60 * 60)
- }
- }
- final class LiveActivityData: ObservableObject {
- /// Determination data used to update live activity state.
- @Published var determination: DeterminationData?
- /// The most recent IoB data
- @Published var iob: Decimal?
- /// Array of glucose readings fetched from persistent storage.
- @Published var glucoseFromPersistence: [GlucoseData]?
- /// The current override data (if any).
- @Published var override: OverrideData?
- /// The widget items displayed within the live activity.
- @Published var widgetItems: [LiveActivityAttributes.LiveActivityItem]?
- }
- /// A service managing live activity updates and state management.
- ///
- /// This class handles the creation, update, and termination of live activities based on various data sources
- /// (e.g. Core Data notifications, glucose updates, settings changes). It integrates with system notifications,
- /// dependency injection, and user defaults to ensure that the live activity reflects the current app state.
- ///
- /// Additionally, it supports a restart functionality (via `restartActivityFromLiveActivityIntent()`)
- /// via iOS shortcuts, similar to other iOS apps like xDrip4iOS or Sweet Dreams.
- @available(iOS 16.2, *) final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver {
- @Injected() private var settingsManager: SettingsManager!
- @Injected() private var broadcaster: Broadcaster!
- @Injected() private var storage: FileStorage!
- @Injected() private var glucoseStorage: GlucoseStorage!
- @Injected() private var iobService: IOBService!
- private let activityAuthorizationInfo = ActivityAuthorizationInfo()
- /// Indicates whether system live activities are enabled.
- @Published private(set) var systemEnabled: Bool
- /// Returns the current Trio settings.
- private var settings: TrioSettings {
- settingsManager.settings
- }
- /// The current active live activity.
- private var currentActivity: ActiveActivity?
- private var data = LiveActivityData()
- /// A Core Data task context.
- let context = CoreDataStack.shared.newTaskContext()
- /// A dispatch queue for handling Core Data change notifications.
- private let queue = DispatchQueue(label: "LiveActivityBridge.queue", qos: .userInitiated)
- private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
- private var subscriptions = Set<AnyCancellable>()
- /// Initializes a new instance of `LiveActivityBridge` and sets up observers, subscribers, and notifications.
- ///
- /// - Parameter resolver: The dependency injection resolver.
- init(resolver: Resolver) {
- coreDataPublisher =
- changedObjectsOnManagedObjectContextDidSavePublisher()
- .receive(on: queue)
- .share()
- .eraseToAnyPublisher()
- systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
- injectServices(resolver)
- setupNotifications()
- registerHandler()
- monitorForLiveActivityAuthorizationChanges()
- broadcaster.register(SettingsObserver.self, observer: self)
- data.objectWillChange.sink { [weak self] in
- Task { @MainActor in
- // by the time this runs, the object change is done, so we see the new data here
- await self?.pushCurrentContent()
- }
- }.store(in: &subscriptions)
- loadInitialData()
- }
- /// Sets up application notifications that trigger live activity updates when the app state changes.
- private func setupNotifications() {
- let notificationCenter = Foundation.NotificationCenter.default
- notificationCenter
- .addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in
- Task { @MainActor in
- await self?.pushCurrentContent()
- }
- }
- notificationCenter
- .addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
- Task { @MainActor in
- await self?.pushCurrentContent()
- }
- }
- notificationCenter.addObserver(
- self,
- selector: #selector(loadWidgetItems),
- name: .liveActivityOrderDidChange,
- object: nil
- )
- }
- /// Called when the app settings change.
- ///
- /// This method triggers an update to the live activity content state based on the new settings.
- /// - Parameter _: The updated `TrioSettings`.
- func settingsDidChange(_: TrioSettings) {
- Task { @MainActor in
- await self.pushCurrentContent()
- }
- }
- /// Registers handlers for Core Data changes related to overrides, glucose readings, and determinations.
- private func registerHandler() {
- coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
- Task { await self?.loadOverrides() }
- }.store(in: &subscriptions)
- coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
- Task { await self?.loadGlucose() }
- }.store(in: &subscriptions)
- coreDataPublisher?.filteredByEntityName("OrefDetermination")
- .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
- .sink { [weak self] _ in
- Task { await self?.loadDetermination() }
- }.store(in: &subscriptions)
- iobService.iobPublisher
- .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
- .sink { [weak self] _ in
- self?.data.iob = self?.iobService.currentIOB
- }.store(in: &subscriptions)
- }
- /// Fetches and maps new determination data and updates the live activity content state.
- private func loadDetermination() async {
- do {
- data.determination = try await fetchAndMapDetermination()
- } catch {
- debug(
- .default,
- "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map determination: \(error)"
- )
- }
- }
- /// Fetches and maps override data and updates the live activity content state.
- private func loadOverrides() async {
- do {
- data.override = try await fetchAndMapOverride()
- } catch {
- debug(.default, "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch and map override: \(error)")
- }
- }
- /// Handles changes to the live activity order.
- ///
- /// Loads widget items from user defaults and triggers an update to the live activity order.
- @objc private func loadWidgetItems() {
- data.widgetItems = UserDefaults.standard.loadLiveActivityOrderFromUserDefaults() ?? LiveActivityAttributes
- .LiveActivityItem.defaultItems
- }
- /// Sets up the array of glucose data from persistent storage and triggers an update to the live activity.
- private func loadGlucose() async {
- do {
- data.glucoseFromPersistence = try await fetchAndMapGlucose()
- } catch {
- debug(
- .default,
- "[LiveActivityManager] \(DebuggingIdentifiers.failed) failed to fetch glucose with error: \(error)"
- )
- }
- }
- private func loadInitialData() {
- Task {
- await self.loadGlucose()
- await self.loadOverrides()
- await self.loadDetermination()
- self.loadWidgetItems()
- }
- }
- /// Monitors live activity authorization changes and updates the `systemEnabled` flag.
- private func monitorForLiveActivityAuthorizationChanges() {
- Task {
- for await activityState in activityAuthorizationInfo.activityEnablementUpdates {
- if activityState != systemEnabled {
- await MainActor.run {
- systemEnabled = activityState
- }
- }
- }
- }
- }
- /// Pushes an update to the live activity with the specified content state.
- ///
- /// If an existing activity requires recreation or is outdated, this method ends it and starts a new one.
- /// Otherwise, it updates the current live activity.
- ///
- /// - Parameter state: The new content state to push to the live activity.
- @MainActor private func pushUpdate(_ state: LiveActivityAttributes.ContentState) async {
- if !settings.useLiveActivity || !systemEnabled {
- await endActivity()
- return
- }
- if currentActivity == nil {
- // try to restore an existing activity
- currentActivity = Activity<LiveActivityAttributes>.activities
- .max { $0.attributes.startDate < $1.attributes.startDate }.map {
- ActiveActivity(activity: $0)
- }
- if let currentActivity {
- debug(.default, "[LiveActivityManager] Restored live activity: \(currentActivity.activity.id)")
- }
- }
- // End all unknown activities except the current one
- for unknownActivity in Activity<LiveActivityAttributes>.activities
- .filter({ self.currentActivity?.activity.id != $0.id })
- {
- await unknownActivity.end(nil, dismissalPolicy: .immediate)
- }
- if let currentActivity {
- if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
- debug(.default, "[LiveActivityManager] Ending current activity for recreation: \(currentActivity.activity.id)")
- await endActivity()
- // After endActivity(), currentActivity is guaranteed to be nil
- // No recursive task, but explicitly restart
- debug(.default, "[LiveActivityManager] Re-pushing update after recreation.")
- await pushUpdate(state)
- } else {
- let content = ActivityContent(
- state: state,
- staleDate: min(state.date ?? Date.now, Date.now).addingTimeInterval(360)
- )
- // Before the update, check if currentActivity is still valid
- if let stillCurrent = self.currentActivity, stillCurrent.activity.id == currentActivity.activity.id {
- debug(.default, "[LiveActivityManager] Updating current activity: \(stillCurrent.activity.id)")
- await stillCurrent.activity.update(content)
- } else {
- debug(.default, "[LiveActivityManager] Skipped update: currentActivity changed during pushUpdate.")
- }
- }
- } else {
- // ... Activity is newly created ...
- do {
- let expired = ActivityContent(
- state: LiveActivityAttributes
- .ContentState(
- unit: settings.units.rawValue,
- bg: "--",
- direction: nil,
- change: "--",
- date: Date.now,
- highGlucose: settings.high,
- lowGlucose: settings.low,
- target: data.determination?.target ?? 100 as Decimal,
- glucoseColorScheme: settings.glucoseColorScheme.rawValue,
- useDetailedViewIOS: false,
- useDetailedViewWatchOS: false,
- detailedViewState: LiveActivityAttributes.ContentAdditionalState(
- chart: [],
- rotationDegrees: 0,
- cob: 0,
- iob: 0,
- tdd: 0,
- isOverrideActive: false,
- overrideName: "",
- overrideDate: Date.now,
- overrideDuration: 0,
- overrideTarget: 0,
- widgetItems: []
- ),
- isInitialState: true
- ),
- staleDate: Date.now.addingTimeInterval(60)
- )
- let activity = try Activity.request(
- attributes: LiveActivityAttributes(startDate: Date.now),
- content: expired,
- pushType: nil
- )
- currentActivity = ActiveActivity(activity: activity)
- debug(.default, "[LiveActivityManager] Created new activity: \(activity.id)")
- // Update the newly created activity with actual data
- let updateContent = ActivityContent(
- state: state,
- staleDate: Date.now.addingTimeInterval(5 * 60)
- )
- await activity.update(updateContent)
- debug(.default, "[LiveActivityManager] Set initial content for new activity: \(activity.id)")
- } catch {
- debug(
- .default,
- "[LiveActivityManager]: Error creating new activity: \(error)"
- )
- // Reset currentActivity on error to allow retry on next update
- currentActivity = nil
- }
- }
- }
- /// Ends the current live activity and ensures that all unknown activities are terminated.
- private func endActivity() async {
- debug(.default, "[LiveActivityManager] Ending all live activities...")
- if let currentActivity {
- debug(.default, "[LiveActivityManager] Ending current activity: \(currentActivity.activity.id)")
- await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
- self.currentActivity = nil
- }
- for unknownActivity in Activity<LiveActivityAttributes>.activities {
- debug(.default, "[LiveActivityManager] Ending unknown activity: \(unknownActivity.id)")
- await unknownActivity.end(nil, dismissalPolicy: .immediate)
- }
- debug(.default, "[LiveActivityManager] All live activities ended.")
- }
- /// Restarts the live activity from a Live Activity Intent.
- ///
- /// This method mimics xdrip's `restartActivityFromLiveActivityIntent()` behavior by verifying that a valid content state
- /// exists,
- /// ending the current live activity, and starting a new one using the current state.
- @MainActor func restartActivityFromLiveActivityIntent() async {
- await endActivity()
- while (currentActivity != nil && currentActivity!.activity.activityState != .ended) || Activity<LiveActivityAttributes>
- .activities.contains(where: { $0.activityState != .ended })
- {
- debug(.default, "[LiveActivityManager] Waiting for Live Activity to end...")
- try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s sleep
- }
- // Add additional delay to ensure iOS has fully cleaned up the previous activity
- debug(.default, "[LiveActivityManager] Waiting additional time for iOS to clean up...")
- try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s additional delay
- await pushCurrentContent()
- debug(.default, "[LiveActivityManager] Restarted Live Activity from LiveActivityIntent (via iOS Shortcut)")
- }
- }
- @available(iOS 16.2, *) extension LiveActivityManager {
- @MainActor func pushCurrentContent() async {
- guard let glucose = data.glucoseFromPersistence, let bg = glucose.first else {
- debug(.default, "[LiveActivityManager] pushCurrentContent: no current glucose data available")
- return
- }
- let prevGlucose = data.glucoseFromPersistence?.dropFirst().first
- guard let determination = data.determination else {
- debug(.default, "[LiveActivityManager] pushCurrentContent: no determination available")
- return
- }
- let content = LiveActivityAttributes.ContentState(
- new: bg,
- prev: prevGlucose,
- units: settings.units,
- chart: glucose,
- settings: settings,
- determination: determination,
- iob: data.iob,
- override: data.override,
- widgetItems: data.widgetItems
- )
- await pushUpdate(content)
- }
- }
|