From 10efdd6e9a9087cb2e49b48848fb3c8d5b1d135f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 9 Sep 2026 10:27:05 +0200 Subject: [PATCH] fix(mobile): skip the iOS glanceable sink on Android Live Activity getInfo is not a function on Android. KILO-APP-9N fired from endNow during publish. Register the iOS sink on iOS only. --- .../src/glanceable-ios/register.test.ts | 53 +++++++++++++ apps/mobile/src/glanceable-ios/register.ts | 76 +++++++++---------- 2 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/glanceable-ios/register.test.ts diff --git a/apps/mobile/src/glanceable-ios/register.test.ts b/apps/mobile/src/glanceable-ios/register.test.ts new file mode 100644 index 00000000000..16ea7ef802f --- /dev/null +++ b/apps/mobile/src/glanceable-ios/register.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + platform: { OS: 'ios' as string }, + iosSink: { + publish: vi.fn(), + endImmediate: vi.fn(), + startOrUpdate: vi.fn(), + }, +})); + +vi.mock('react-native', () => ({ + Platform: mocks.platform, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, + PlatformColor: (name: string) => name, +})); + +vi.mock('./ios-sink', () => ({ iosSink: mocks.iosSink })); +vi.mock('./adopt-activity', () => ({ adoptPushStartedActivity: vi.fn() })); +vi.mock('./active-agents-live-activity', () => ({ + refreshActiveAgentsLiveActivityCopy: vi.fn(), +})); +vi.mock('./active-agents-widget', () => ({ + refreshActiveAgentsWidgetCopy: vi.fn(), +})); +vi.mock('./widget-logo', () => ({ ensureWidgetLogo: vi.fn() })); +vi.mock('@/i18n', () => ({ i18n: { on: vi.fn(), t: (key: string) => key } })); +vi.mock('@/lib/glanceable/live-activity-switch', () => ({ + getLiveActivityEnabled: () => true, + subscribeLiveActivityEnabled: vi.fn(), +})); + +describe('glanceable-ios register', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('does not register the iOS sink on Android', async () => { + mocks.platform.OS = 'android'; + vi.resetModules(); + const { getGlanceableSinks } = await import('@/lib/glanceable/sink-registry'); + await import('./register'); + expect(getGlanceableSinks()).not.toContain(mocks.iosSink); + }); + + it('registers the iOS sink on iOS', async () => { + mocks.platform.OS = 'ios'; + vi.resetModules(); + const { getGlanceableSinks } = await import('@/lib/glanceable/sink-registry'); + await import('./register'); + expect(getGlanceableSinks()).toContain(mocks.iosSink); + }); +}); diff --git a/apps/mobile/src/glanceable-ios/register.ts b/apps/mobile/src/glanceable-ios/register.ts index 1b617bdaa08..d3f1f243ca4 100644 --- a/apps/mobile/src/glanceable-ios/register.ts +++ b/apps/mobile/src/glanceable-ios/register.ts @@ -13,47 +13,47 @@ import { refreshActiveAgentsWidgetCopy } from './active-agents-widget'; import { iosSink } from './ios-sink'; import { ensureWidgetLogo } from './widget-logo'; -// Registers the iOS Live Activity and widget sink at import time. The root -// layout imports this file, so the surface lifecycle subscribes to the -// publisher for the whole process on both platforms. Never create a React -// dependency here: the publisher is plain state, and widgets get translated -// copy through the sink, not through a mounted component tree. -registerGlanceableSink(iosSink); +if (Platform.OS === 'ios') { + // Registers the iOS Live Activity and widget sink at import time. The root + // layout imports this file on both platforms; Android owns + // glanceable-android/register. Never create a React dependency here: the + // publisher is plain state, and widgets get translated copy through the sink, + // not through a mounted component tree. + registerGlanceableSink(iosSink); -// Copy the Kilo mark into the shared app group so the widget extension can read -// it. Fire and forget: it lands long before the first snapshot arrives, and a -// failure only costs the logo. -void ensureWidgetLogo(); + // Copy the Kilo mark into the shared app group so the widget extension can read + // it. Fire and forget: it lands long before the first snapshot arrives, and a + // failure only costs the logo. + void ensureWidgetLogo(); -// Claim a card raised by a push-to-start before anything else runs. iOS grants -// this process background run time for exactly that, and the server cannot -// update or end the card until its update token arrives. -void adoptPushStartedActivity(); + // Claim a card raised by a push-to-start before anything else runs. iOS grants + // this process background run time for exactly that, and the server cannot + // update or end the card until its update token arrives. + void adoptPushStartedActivity(); -// The layouts bake their copy in at import, when i18n still holds English: the -// stored language is applied a few ticks later. Re-bake on every language -// change so both the Live Activity and the widget gallery placeholder follow -// the user's language. -i18n.on('languageChanged', () => { - refreshActiveAgentsLiveActivityCopy(); - refreshActiveAgentsWidgetCopy(); -}); + // The layouts bake their copy in at import, when i18n still holds English: the + // stored language is applied a few ticks later. Re-bake on every language + // change so both the Live Activity and the widget gallery placeholder follow + // the user's language. + i18n.on('languageChanged', () => { + refreshActiveAgentsLiveActivityCopy(); + refreshActiveAgentsWidgetCopy(); + }); -// Turning the in-app switch off must clear the activity already on the Lock -// Screen, not just stop the next start. `startOrUpdate` holds the guard for -// everything after this. -let liveActivityAllowed = getLiveActivityEnabled(); -subscribeLiveActivityEnabled(() => { - const next = getLiveActivityEnabled(); - if (liveActivityAllowed && !next) { - iosSink.endImmediate(); - // `endImmediate` retires only the activity tokens. The push-to-start - // subscription outlives them, so a remote start would still reach a - // switched-off surface. `canRegisterActivityTokenKind` keeps it retired - // until the switch returns. - if (Platform.OS === 'ios') { + // Turning the in-app switch off must clear the activity already on the Lock + // Screen, not just stop the next start. `startOrUpdate` holds the guard for + // everything after this. + let liveActivityAllowed = getLiveActivityEnabled(); + subscribeLiveActivityEnabled(() => { + const next = getLiveActivityEnabled(); + if (liveActivityAllowed && !next) { + iosSink.endImmediate(); + // `endImmediate` retires only the activity tokens. The push-to-start + // subscription outlives them, so a remote start would still reach a + // switched-off surface. `canRegisterActivityTokenKind` keeps it retired + // until the switch returns. getGlanceableDelivery().cleanupTokens('scope'); } - } - liveActivityAllowed = next; -}); + liveActivityAllowed = next; + }); +}