From b3f0826122eb70959f43031d6be9ad88dd42f8f0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 6 Jul 2026 23:05:24 +0200 Subject: [PATCH] Added the "reset()" method to the Reporting protocol in the Recording feature target to discard leftover Live Activities at launch and set a stale date on recording activities. --- .../Sources/Services/ActivityReporting.swift | 30 +++++++++++++++++-- .../View Models/ContentViewModel.swift | 14 ++++++++- .../Recording/Protocols/Reporting.swift | 5 ++++ .../Recording/Services/DummyReporting.swift | 3 ++ .../Tests/Recording/Mocks/ReportingMock.swift | 4 +++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Apps/Attendi/Sources/Services/ActivityReporting.swift b/Apps/Attendi/Sources/Services/ActivityReporting.swift index 1e361e6..34f67d9 100644 --- a/Apps/Attendi/Sources/Services/ActivityReporting.swift +++ b/Apps/Attendi/Sources/Services/ActivityReporting.swift @@ -49,7 +49,7 @@ final class ActivityReporting: Reporting { anchor: anchor, elapsed: .zero ), - staleDate: nil + staleDate: Date.now.addingTimeInterval(Constant.Time.stale) ) ) #endif @@ -112,6 +112,19 @@ final class ActivityReporting: Reporting { #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) @@ -137,10 +150,23 @@ private extension ActivityReporting { anchor: anchor, elapsed: elapsed ), - staleDate: nil + 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 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 diff --git a/Apps/Attendi/Sources/View Models/ContentViewModel.swift b/Apps/Attendi/Sources/View Models/ContentViewModel.swift index d3a0765..9d7fef0 100644 --- a/Apps/Attendi/Sources/View Models/ContentViewModel.swift +++ b/Apps/Attendi/Sources/View Models/ContentViewModel.swift @@ -38,6 +38,10 @@ extension ContentView { @ObservationIgnored let capturer: any Capturing + /// Whether the reporting surfaces left over from an earlier run have been discarded, so the discard runs only once per launch. + @ObservationIgnored + private var didReset = false + /// The locale whose speech model assets are preinstalled or being preinstalled, or `nil` when none are. @ObservationIgnored private var localePreinstalling: Locale? @@ -96,7 +100,9 @@ extension ContentView { // MARK: Methods - /// Loads the locales the transcriber supports — resolved through the preinstalling service — into ``locales``, sorted by their + /// Discards, once per launch, any reporting surface left over from an earlier run — a Live Activity the app was killed before it + /// could end, for example — since a fresh launch has no recording it could still belong to. It then loads the locales the + /// transcriber supports — resolved through the preinstalling service — into ``locales``, sorted by their /// localized names, and aligns ``locale`` with the supported equivalent of its current value — restored first from the given /// persisted identifier when one exists, and falling back to the supported equivalent of a default locale when no equivalent /// exists — so the locale picker starts with a valid selection. It then kicks off the preinstallation of the aligned locale's @@ -107,6 +113,12 @@ extension ContentView { func load( identifier: String ) async { + if !didReset { + didReset = true + + await reporter.reset() + } + if !identifier.isEmpty { locale = .init(identifier: identifier) } diff --git a/Packages/Features/Sources/Recording/Protocols/Reporting.swift b/Packages/Features/Sources/Recording/Protocols/Reporting.swift index 36e38f2..2834058 100644 --- a/Packages/Features/Sources/Recording/Protocols/Reporting.swift +++ b/Packages/Features/Sources/Recording/Protocols/Reporting.swift @@ -33,4 +33,9 @@ public protocol Reporting: Sendable { /// Reports the end of the recording flow. func ended() async + /// Discards any reporting surface left over from an earlier run of the app — a Live Activity the app was killed before it could + /// end, for example — so an abandoned surface never outlives the process that created it. Meant to be called once at launch, + /// before any recording starts. + func reset() async + } diff --git a/Packages/Features/Sources/Recording/Services/DummyReporting.swift b/Packages/Features/Sources/Recording/Services/DummyReporting.swift index 15eeac0..79a7b4c 100644 --- a/Packages/Features/Sources/Recording/Services/DummyReporting.swift +++ b/Packages/Features/Sources/Recording/Services/DummyReporting.swift @@ -23,4 +23,7 @@ struct DummyReporting: Reporting { /// Swallows the end of the recording flow. func ended() async {} + /// Swallows the request to discard any leftover reporting surface. + func reset() async {} + } diff --git a/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift b/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift index f6861e7..ddbf1b1 100644 --- a/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift +++ b/Packages/Features/Tests/Recording/Mocks/ReportingMock.swift @@ -50,4 +50,8 @@ final class ReportingMock: Reporting { reports.append("ended") } + func reset() async { + reports.append("reset") + } + }