Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/mobile/src/glanceable-ios/register.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
76 changes: 38 additions & 38 deletions apps/mobile/src/glanceable-ios/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}