#if os(iOS) @preconcurrency import ActivityKit #endif import Commanding import Foundation import Recording /// The reporting service that surfaces the recording flow in a Live Activity on the Lock Screen and in the Dynamic Island. /// /// The service implements the `Recording` feature's `Reporting` port over the Live Activity vocabulary shared with the widget /// extension through the `Commanding` target: it starts a Live Activity when a recording starts, updates its content on every pause, /// resumption, and processing transition, and ends it — dismissing it right away — when the flow ends. The activity is an auxiliary /// surface of the recording flow, never a required one: a start that fails, or that the user disallowed in the system settings, is /// silently ignored, and the platforms without Live Activities reduce the whole service to a no-op. @MainActor final class ActivityReporting: Reporting { // MARK: Properties #if os(iOS) /// The Live Activity reflecting the ongoing recording flow, or `nil` while none is. private var activity: Activity? #endif // MARK: Initializers /// Creates a reporting service with no Live Activity started yet. init() {} // MARK: Methods /// Starts a Live Activity for a new recording, unless the user disallowed Live Activities or the system refuses to start one. /// /// - Parameter anchor: The instant the elapsed recording time counts from. func started( at anchor: Date ) async { #if os(iOS) guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } activity = try? Activity.request( attributes: RecordingActivityAttributes(), content: .init( state: .init( state: .recording, anchor: anchor.addingTimeInterval(Constant.Time.flooring), elapsed: .zero ), staleDate: Date.now.addingTimeInterval(Constant.Time.stale) ) ) #endif } /// Updates the Live Activity with the pause of the ongoing recording, freezing its timer at the elapsed recording time. /// /// - Parameter elapsed: The number of seconds spent recording so far, excluding any time spent paused. func paused( elapsed: TimeInterval ) async { #if os(iOS) await update( state: .paused, anchor: nil, elapsed: elapsed.rounded(.down) ) #endif } /// Updates the Live Activity with the resumption of a paused recording, restarting its timer from the given anchor. /// /// - Parameter anchor: The instant the elapsed recording time counts from, moved back by the time already spent recording. func resumed( anchor: Date ) async { #if os(iOS) await update( state: .recording, anchor: anchor.addingTimeInterval(Constant.Time.flooring), elapsed: .zero ) #endif } /// Updates the Live Activity with the processing of the recorded input, freezing its timer at the elapsed recording time. /// /// - Parameter elapsed: The number of seconds spent recording, excluding any time spent paused. func processing( elapsed: TimeInterval ) async { #if os(iOS) await update( state: .processing, anchor: nil, elapsed: elapsed.rounded(.down) ) #endif } /// Ends the Live Activity of the recording flow, dismissing it right away. func ended() async { #if os(iOS) await activity?.end( activity?.content, dismissalPolicy: .immediate ) activity = nil #endif } /// Ends every recording Live Activity still around — including one left over from an earlier run of the app the system killed before /// it could end its own — dismissing them right away, so an abandoned activity never outlives the process that started it. Meant to /// run once at launch, before any recording starts, since a fresh launch has no recording an activity could still belong to. func reset() async { #if os(iOS) for activity in Activity.activities { await activity.end(nil, dismissalPolicy: .immediate) } activity = nil #endif } } #if os(iOS) // MARK: - Helpers private extension ActivityReporting { /// Updates the dynamic content of the Live Activity, when one is running. /// /// - Parameters: /// - state: The state of the recording flow to show. /// - anchor: The instant the elapsed recording time counts from, or `nil` while the recording is not running. /// - elapsed: The number of seconds spent recording up to the transition, excluding any time spent paused. func update( state: RecordingActivityState, anchor: Date?, elapsed: TimeInterval ) async { await activity?.update( .init( state: .init( state: state, anchor: anchor, elapsed: elapsed ), staleDate: Date.now.addingTimeInterval(Constant.Time.stale) ) ) } } // MARK: - Constants /// The constant values used across the reporting service. private enum Constant { /// The time constants. enum Time { /// The half second added to a running timer's anchor. The system counts the Live Activity's timer up from the anchor and /// formats it with round-to-nearest, whereas the feature's own timer floors the elapsed seconds; moving the anchor half a /// second into the future turns that rounding back into flooring, so both timers show the same whole second instead of the /// Live Activity ticking one second ahead for the back half of each second. static let flooring: TimeInterval = 0.5 /// The span past which the system marks the recording Live Activity stale once the app stops updating it — because it was /// killed, for example — so an abandoned activity stops looking live instead of lingering for the activity's full lifetime. /// Generous enough to outlast a recording of any realistic length. static let stale: TimeInterval = 60 * 60 } } #endif