From 92b336329b0c99d60459f48afbb2d471775901da Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 09:52:45 -0500 Subject: [PATCH 1/4] test(mobile): cover ended Live Activity dismissal on foreground and sign-out Co-authored-by: EMOJAI <78482940+EMOEMOJAI@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../remoteRegistration.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c3ec5f155958..91ede75cf3b5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -57,6 +57,7 @@ vi.mock("./androidNotifications", () => ({ const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ + dismissEnded: vi.fn(), getInstances: vi.fn(() => []), start: vi.fn(() => ({})), })); @@ -97,6 +98,7 @@ vi.mock("expo-widgets", () => ({ })); vi.mock("./agentLiveActivity", () => ({ + dismissEndedAgentLiveActivities: widgetMocks.dismissEnded, getAgentLiveActivities: widgetMocks.getInstances, startAgentLiveActivity: widgetMocks.start, })); @@ -270,6 +272,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); @@ -499,6 +502,43 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + it.effect( + "dismisses ended Live Activities before foreground discovery with no work running", + () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); + expect(widgetMocks.dismissEnded.mock.invocationCallOrder[0]).toBeLessThan( + widgetMocks.getInstances.mock.invocationCallOrder[0]!, + ); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerRelayTest)); + }, + ); + + it.effect("still registers the active card when ended-card cleanup throws", () => { + widgetMocks.dismissEnded.mockImplementation(() => { + throw new Error("Cannot find function dismissEnded"); + }); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); + + expect(activity.getPushToken).toHaveBeenCalled(); + expect(activity.end).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerRelayTest)); + }); + it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { const end = vi.fn(() => Promise.resolve()); const activity = { @@ -513,6 +553,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); expect(end).toHaveBeenCalledWith("immediate"); + expect(widgetMocks.dismissEnded).toHaveBeenCalled(); expect(appStateMock.listeners).toHaveLength(0); }); From b595a94e4a29b666b0ea9f7d9a705dcec87f2594 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 09:52:45 -0500 Subject: [PATCH 2/4] fix(mobile): dismiss ended Live Activities when the app returns to the foreground Co-authored-by: EMOJAI <78482940+EMOEMOJAI@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../agent-awareness/agentLiveActivity.ios.ts | 4 ++ .../agent-awareness/agentLiveActivity.ts | 2 + .../agent-awareness/remoteRegistration.ts | 17 ++++- patches/expo-widgets@58.0.20.patch | 65 +++++++++++++++++-- pnpm-lock.yaml | 8 +-- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts index 1cb7369af559..32141f220186 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts @@ -1,5 +1,9 @@ import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +export function dismissEndedAgentLiveActivities() { + AgentActivity.dismissEnded(); +} + export function getAgentLiveActivities() { return AgentActivity.getInstances(); } diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts index 18c3ffcf519b..684c57817eca 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts @@ -1,6 +1,8 @@ import type { LiveActivity } from "expo-widgets"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +export function dismissEndedAgentLiveActivities(): void {} + export function getAgentLiveActivities(): Array> { return []; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 8212b02965cc..0e84c080e17d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -36,7 +36,11 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; -import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity"; +import { + dismissEndedAgentLiveActivities, + getAgentLiveActivities, + startAgentLiveActivity, +} from "./agentLiveActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -804,10 +808,20 @@ function ensureAppStateListener(): void { }); } +// Binaries built before the native dismissEnded method throw here. +function dismissEndedLocalLiveActivities(): void { + try { + dismissEndedAgentLiveActivities(); + } catch (error) { + logRegistrationError("ended live activity cleanup failed", error); + } +} + function endLocalLiveActivities(context: string): void { if (!canRegisterRemoteLiveActivities()) { return; } + dismissEndedLocalLiveActivities(); try { for (const activity of getAgentLiveActivities()) { activity.end("immediate").catch((error: unknown) => { @@ -1033,6 +1047,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< return; } + dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: (cause) => diff --git a/patches/expo-widgets@58.0.20.patch b/patches/expo-widgets@58.0.20.patch index 8b9db8b9d23a..c0e89b343feb 100644 --- a/patches/expo-widgets@58.0.20.patch +++ b/patches/expo-widgets@58.0.20.patch @@ -1,3 +1,30 @@ +diff --git a/build/Widgets.d.ts b/build/Widgets.d.ts +index fb37d5d..47a97af 100644 +--- a/build/Widgets.d.ts ++++ b/build/Widgets.d.ts +@@ -100,6 +100,8 @@ export declare class LiveActivityFactory { + * @platform ios + */ + getInstances(): LiveActivity[]; ++ /** Dismisses ended instances that iOS still shows on the Lock Screen. */ ++ dismissEnded(): void; + } + /** + * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. +diff --git a/build/Widgets.js b/build/Widgets.js +index cc1efce..3ce8897 100644 +--- a/build/Widgets.js ++++ b/build/Widgets.js +@@ -134,6 +134,9 @@ import ExpoWidgetsModule from './ExpoWidgets'; + */ getInstances() { + return this.nativeLiveActivityFactory.getInstances().map((instance)=>new LiveActivity(instance)); + } ++ dismissEnded() { ++ this.nativeLiveActivityFactory.dismissEnded(); ++ } + } + /** + * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts index 4c474bb..b0173df 100644 --- a/build/Widgets.types.d.ts @@ -14,20 +41,29 @@ index 4c474bb..b0173df 100644 * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift -index 29e4f15..7143f9a 100644 +index 29e4f15..629e547 100644 --- a/ios/LiveActivityFactory.swift +++ b/ios/LiveActivityFactory.swift -@@ -19,6 +19,14 @@ final class LiveActivityFactory: SharedObject { - throw LiveActivitiesNotSupportedException() - } +@@ -14,11 +14,23 @@ final class LiveActivityFactory: SharedObject { + WidgetsStorage.set(layout, forKey: "__expo_widgets_live_activity_\(name)_layout") + } -+ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to -+ // 4 more. getInstances() can't see it, so a caller starting a replacement would leave -+ // both cards visible. Dismiss the ended ones now. ++ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to ++ // 4 more. getInstances() can't see it, so dismiss the ended ones directly. ++ func dismissEnded() { + for activity in Activity.activities + where activity.content.state.name == name && activity.activityState == .ended { + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } ++ } ++ + func start(props: String?, url: URL?, staleDate: Date?) throws -> LiveActivity { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + throw LiveActivitiesNotSupportedException() + } + ++ // A replacement started beside an ended card would leave both visible. ++ dismissEnded() + do { let initialState = LiveActivityAttributes.ContentState(name: name, props: props) @@ -150,6 +186,21 @@ index 4d135c4..dae4744 100644 if #available(iOS 18.0, *) { LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { +diff --git a/ios/WidgetsModule.swift b/ios/WidgetsModule.swift +index 285136b..d36eac4 100644 +--- a/ios/WidgetsModule.swift ++++ b/ios/WidgetsModule.swift +@@ -97,6 +97,10 @@ public final class WidgetsModule: Module { + Function("getInstances") { (liveActivity: LiveActivityFactory) in + try liveActivity.getInstances() + } ++ ++ Function("dismissEnded") { (liveActivity: LiveActivityFactory) in ++ liveActivity.dismissEnded() ++ } + } + + Class("LiveActivity", LiveActivity.self) { diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts index 690d069..ffab6f3 100644 --- a/src/Widgets.types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97f3e0f0ba5d..430f3b8cf03a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,7 +164,7 @@ patchedDependencies: dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 2c2f8c73a23d48804c8b537b8cd73c25eb331aa47d82eaa481c6766cb81e5add expo-sharing@58.0.19: ad248759641ddd71b8331c09dbb3b18c8a551785f2df507533805babd3c0c124 - expo-widgets@58.0.20: 8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd + expo-widgets@58.0.20: 86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-keyboard-controller@1.22.7: f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb @@ -523,7 +523,7 @@ importers: version: 58.0.6(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.20 - version: 58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -18443,7 +18443,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) - expo-widgets: 58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -19896,7 +19896,7 @@ snapshots: expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - expo-widgets@58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-widgets@58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) From 686225ef68d15b5d8886e47bb754a4aea9b6dc9a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 16:01:11 -0500 Subject: [PATCH 3/4] test(mobile): give the cleanup-throws fake activity an id Co-Authored-By: Claude Opus 5.5 --- .../src/features/agent-awareness/remoteRegistration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 91ede75cf3b5..13285fc4a0f3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -524,6 +524,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { throw new Error("Cannot find function dismissEnded"); }); const activity = { + getId: () => "activity-1", getPushToken: vi.fn(() => Promise.resolve("activity-token")), addPushTokenListener: vi.fn(), end: vi.fn(), From a530b2de677bb8ab592393e28470295fbeb39a27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 19:32:01 +0000 Subject: [PATCH 4/4] fix(mobile): complete ended activity cleanup API --- .../remoteRegistration.test.ts | 73 +++++++++++++------ patches/expo-widgets@58.0.20.patch | 62 ++++++++++++++-- pnpm-lock.yaml | 8 +- 3 files changed, 111 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9d365e81f012..74c842be540f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -624,42 +624,73 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); - it.effect( - "dismisses ended Live Activities before foreground discovery with no work running", - () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + it.effect("dismisses ended Live Activities when the idle app returns to the foreground", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - return Effect.gen(function* () { - yield* refreshActiveLiveActivityRemoteRegistration(); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + widgetMocks.dismissEnded.mockClear(); - expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); - expect(widgetMocks.dismissEnded.mock.invocationCallOrder[0]).toBeLessThan( - widgetMocks.getInstances.mock.invocationCallOrder[0]!, - ); - expect(widgetMocks.start).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); + for (const listener of appStateMock.listeners) listener("background"); + yield* runBackgroundOperations(); + expect(widgetMocks.dismissEnded).not.toHaveBeenCalled(); + + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + + expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerRelayTest)); + }); it.effect("still registers the active card when ended-card cleanup throws", () => { - widgetMocks.dismissEnded.mockImplementation(() => { - throw new Error("Cannot find function dismissEnded"); + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + if (request.url.endsWith("/v1/mobile/live-activities")) { + registrations.push(await request.json()); + } + return Response.json({ ok: true }); }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); const activity = { getId: () => "activity-1", getPushToken: vi.fn(() => Promise.resolve("activity-token")), addPushTokenListener: vi.fn(), end: vi.fn(), }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* refreshActiveLiveActivityRemoteRegistration(); + yield* runBackgroundOperations(); + widgetMocks.dismissEnded.mockReset(); + widgetMocks.dismissEnded.mockImplementation(() => { + throw new Error("Cannot find function dismissEnded"); + }); + widgetMocks.getInstances.mockReturnValue([activity] as never); - expect(activity.getPushToken).toHaveBeenCalled(); + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + + expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); + expect(registrations).toEqual([ + { deviceId: "device-1", activityPushToken: "activity-token" }, + ]); expect(activity.end).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); }); it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { diff --git a/patches/expo-widgets@58.0.20.patch b/patches/expo-widgets@58.0.20.patch index c0e89b343feb..21252dcd848a 100644 --- a/patches/expo-widgets@58.0.20.patch +++ b/patches/expo-widgets@58.0.20.patch @@ -1,3 +1,15 @@ +diff --git a/build/ExpoWidgets.js b/build/ExpoWidgets.js +index 85f3fbd..05b8dd8 100644 +--- a/build/ExpoWidgets.js ++++ b/build/ExpoWidgets.js +@@ -34,6 +34,7 @@ class LiveActivityFactoryStub { + getInstances() { + return []; + } ++ dismissEnded() {} + } + class CustomNotificationStub { + getId() { diff --git a/build/Widgets.d.ts b/build/Widgets.d.ts index fb37d5d..47a97af 100644 --- a/build/Widgets.d.ts @@ -26,7 +38,7 @@ index cc1efce..3ce8897 100644 /** * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index 4c474bb..b0173df 100644 +index 4c474bb..dbfb8b1 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts @@ -98,6 +98,10 @@ export type LiveActivityEnvironment = { @@ -40,11 +52,19 @@ index 4c474bb..b0173df 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +@@ -307,6 +311,7 @@ export declare class NativeLiveActivityFactory extends SharedObject { + constructor(name: string, layout: string); + start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; + getInstances(): NativeLiveActivity[]; ++ dismissEnded(): void; + } + export declare class NativeLiveActivity extends SharedObject { + getId(): string; diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift index 29e4f15..629e547 100644 --- a/ios/LiveActivityFactory.swift +++ b/ios/LiveActivityFactory.swift -@@ -14,11 +14,23 @@ final class LiveActivityFactory: SharedObject { +@@ -14,7 +14,16 @@ final class LiveActivityFactory: SharedObject { WidgetsStorage.set(layout, forKey: "__expo_widgets_live_activity_\(name)_layout") } @@ -61,7 +81,7 @@ index 29e4f15..629e547 100644 guard ActivityAuthorizationInfo().areActivitiesEnabled else { throw LiveActivitiesNotSupportedException() } - +@@ -22,3 +31,6 @@ final class LiveActivityFactory: SharedObject { + // A replacement started beside an ended card would leave both visible. + dismissEnded() + @@ -190,7 +210,7 @@ diff --git a/ios/WidgetsModule.swift b/ios/WidgetsModule.swift index 285136b..d36eac4 100644 --- a/ios/WidgetsModule.swift +++ b/ios/WidgetsModule.swift -@@ -97,6 +97,10 @@ public final class WidgetsModule: Module { +@@ -97,4 +97,8 @@ public final class WidgetsModule: Module { Function("getInstances") { (liveActivity: LiveActivityFactory) in try liveActivity.getInstances() } @@ -199,10 +219,32 @@ index 285136b..d36eac4 100644 + liveActivity.dismissEnded() + } } - - Class("LiveActivity", LiveActivity.self) { +diff --git a/src/ExpoWidgets.ts b/src/ExpoWidgets.ts +index db15000..6863dc9 100644 +--- a/src/ExpoWidgets.ts ++++ b/src/ExpoWidgets.ts +@@ -59,4 +59,5 @@ class LiveActivityFactoryStub { + getInstances(): NativeLiveActivity[] { + return []; + } ++ dismissEnded(): void {} + } +diff --git a/src/Widgets.ts b/src/Widgets.ts +index 814b312..9818907 100644 +--- a/src/Widgets.ts ++++ b/src/Widgets.ts +@@ -213,4 +213,9 @@ export class LiveActivityFactory { + .getInstances() + .map((instance) => new LiveActivity(instance)); + } ++ ++ /** Dismisses ended instances that iOS still shows on the Lock Screen. */ ++ dismissEnded(): void { ++ this.nativeLiveActivityFactory.dismissEnded(); ++ } + } diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 690d069..ffab6f3 100644 +index 690d069..c60dd79 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts @@ -112,6 +112,10 @@ export type LiveActivityEnvironment = { @@ -216,3 +258,9 @@ index 690d069..ffab6f3 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +@@ -337,4 +341,5 @@ export declare class NativeLiveActivityFactory extends SharedObject { + constructor(name: string, layout: string); + start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; + getInstances(): NativeLiveActivity[]; ++ dismissEnded(): void; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07990c02dbdb..38b8a830c146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ patchedDependencies: dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 2c2f8c73a23d48804c8b537b8cd73c25eb331aa47d82eaa481c6766cb81e5add expo-sharing@58.0.19: ad248759641ddd71b8331c09dbb3b18c8a551785f2df507533805babd3c0c124 - expo-widgets@58.0.20: 86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0 + expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-keyboard-controller@1.22.7: f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb @@ -542,7 +542,7 @@ importers: version: 58.0.6(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.20 - version: 58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: specifier: 'catalog:' version: 19.3.0 @@ -18415,7 +18415,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) - expo-widgets: 58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -19874,7 +19874,7 @@ snapshots: expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - expo-widgets@58.0.20(patch_hash=86ce46c3ec73d44026f98dbe4d734910d4d90fd23630464e73d56fa29a68dfe0)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)