From 464e03153a30494d7a021cfc848c942d074522d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 27 Aug 2026 13:46:53 +0200 Subject: [PATCH 1/6] feat(mobile): add Android agents widget and live update --- .../android/build.gradle | 22 ++ .../ActiveAgentsLiveUpdateModule.kt | 104 ++++++++ .../expo-module.config.json | 4 +- .../active-agents-widget.test.ts | 150 +++++++++++ .../active-agents-widget.tsx | 131 ++++++++++ .../glanceable-android/android-sink.test.ts | 246 ++++++++++++++++++ .../src/glanceable-android/android-sink.ts | 199 ++++++++++++++ .../src/glanceable-android/live-update.ts | 36 +++ .../glanceable-android/permission-alert.ts | 27 ++ .../src/glanceable-android/permission.ts | 33 +++ .../mobile/src/glanceable-android/register.ts | 36 +++ .../src/glanceable-android/widget-config.json | 16 ++ .../glanceable-android/widget-props.test.ts | 115 ++++++++ .../src/glanceable-android/widget-props.ts | 114 ++++++++ apps/mobile/vitest.pure.config.ts | 1 + 15 files changed, 1232 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/modules/active-agents-live-update/android/build.gradle create mode 100644 apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget.test.ts create mode 100644 apps/mobile/src/glanceable-android/active-agents-widget.tsx create mode 100644 apps/mobile/src/glanceable-android/android-sink.test.ts create mode 100644 apps/mobile/src/glanceable-android/android-sink.ts create mode 100644 apps/mobile/src/glanceable-android/live-update.ts create mode 100644 apps/mobile/src/glanceable-android/permission-alert.ts create mode 100644 apps/mobile/src/glanceable-android/permission.ts create mode 100644 apps/mobile/src/glanceable-android/register.ts create mode 100644 apps/mobile/src/glanceable-android/widget-config.json create mode 100644 apps/mobile/src/glanceable-android/widget-props.test.ts create mode 100644 apps/mobile/src/glanceable-android/widget-props.ts diff --git a/apps/mobile/modules/active-agents-live-update/android/build.gradle b/apps/mobile/modules/active-agents-live-update/android/build.gradle new file mode 100644 index 0000000000..69271ab620 --- /dev/null +++ b/apps/mobile/modules/active-agents-live-update/android/build.gradle @@ -0,0 +1,22 @@ +apply plugin: 'com.android.library' + +group = 'com.kilocode.activeagentsliveupdate' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() + +android { + namespace "com.kilocode.activeagentsliveupdate" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} \ No newline at end of file diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt new file mode 100644 index 0000000000..9444a5fea8 --- /dev/null +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -0,0 +1,104 @@ +package com.kilocode.activeagentsliveupdate + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Local Expo module for the Android aggregate ongoing notification. + * + * The JS side owns the translated copy and the revision guard; this module owns + * the fixed notification id, the dedicated `active-agents` channel (default + * importance, silent, no heads-up), and the API 36.1+ promotion gate. + */ +class ActiveAgentsLiveUpdateModule : Module() { + override fun definition() = ModuleDefinition { + Name("ActiveAgentsLiveUpdate") + + Function("isPromotionCapable") { + isPromotionCapable() + } + + Function("start") { title: String, text: String, promotion: Boolean -> + post(title, text, promotion) + } + + Function("update") { title: String, text: String, promotion: Boolean -> + post(title, text, promotion) + } + + Function("end") { + dismiss() + } + } + + private val context: Context + get() = appContext.reactContext ?: appContext.applicationContext + + private val notificationManager: NotificationManager + get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun smallIconId(): Int = + context.resources.getIdentifier("notification_icon", "drawable", context.packageName) + + private fun isPromotionCapable(): Boolean = + Build.VERSION.SDK_INT_FULL >= 36_001_000 && notificationManager.canPostPromotedNotifications() + + private fun ensureChannel(title: String) { + if (Build.VERSION.SDK_INT < 26) { + return + } + if (notificationManager.getNotificationChannel(CHANNEL_ID) != null) { + return + } + val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_DEFAULT) + channel.setSound(null, null) + channel.enableVibration(false) + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + notificationManager.createNotificationChannel(channel) + } + + private fun newBuilder(title: String): Notification.Builder { + if (Build.VERSION.SDK_INT >= 26) { + ensureChannel(title) + return Notification.Builder(context, CHANNEL_ID) + } + return legacyBuilder() + } + + @Suppress("DEPRECATION") + private fun legacyBuilder(): Notification.Builder = Notification.Builder(context) + + private fun post(title: String, text: String, promotion: Boolean) { + val builder = newBuilder(title) + .setSmallIcon(smallIconId()) + .setContentTitle(title) + .setContentText(text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSound(null) + .setCategory(Notification.CATEGORY_STATUS) + + // API 36.1+ Live Update: promote only when the device reports the capability. + // setRequestPromotedOngoing does not exist; use the documented flag setter. + if (promotion && isPromotionCapable()) { + builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true) + builder.setStyle(Notification.ProgressStyle()) + } + + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun dismiss() { + notificationManager.cancel(NOTIFICATION_ID) + } + + private companion object { + const val CHANNEL_ID = "active-agents" + const val NOTIFICATION_ID = 1001 + } +} \ No newline at end of file diff --git a/apps/mobile/modules/active-agents-live-update/expo-module.config.json b/apps/mobile/modules/active-agents-live-update/expo-module.config.json index 77a2a11327..6c19feec99 100644 --- a/apps/mobile/modules/active-agents-live-update/expo-module.config.json +++ b/apps/mobile/modules/active-agents-live-update/expo-module.config.json @@ -4,6 +4,6 @@ "modules": [] }, "android": { - "modules": [] + "modules": ["com.kilocode.activeagentsliveupdate.ActiveAgentsLiveUpdateModule"] } -} +} \ No newline at end of file diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts new file mode 100644 index 0000000000..12887b7ca8 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -0,0 +1,150 @@ +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { describe, expect, it, vi } from 'vitest'; + +import { OPEN_AGENTS_CLICK, renderActiveAgentsWidget } from './active-agents-widget'; +import { buildAndroidWidgetProps } from './widget-props'; + +// Stub the widget primitives so the layout functions return inspectable trees +// without loading react-native. The real components are exercised by prebuild. +vi.mock('react-native-android-widget', () => ({ + FlexWidget: (props: Record) => ({ kind: 'FlexWidget', props }), + TextWidget: (props: Record) => ({ kind: 'TextWidget', props }), + requestWidgetUpdate: () => undefined, +})); + +const NOW = 1_750_000_000_000; + +type MockElement = { + kind: string; + props: { + text?: string; + clickAction?: string; + style?: { backgroundColor?: string }; + children?: unknown; + }; +}; + +const COPY: Record = { + 'glanceable.needsInput': 'Needs input', + 'glanceable.reconnecting': 'Reconnecting', + 'glanceable.running': 'Running', + 'glanceable.empty': 'No work in progress', + 'glanceable.expired': 'Status expired', + 'glanceable.openAgents': 'Open agents', +}; + +function translate(key: string): string { + return COPY[key] ?? key; +} + +function snapshotFor( + sessions: { status: string }[], + revision = 0, + status?: GlanceableAgentsSnapshot['status'] +): GlanceableAgentsSnapshot { + return buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + previousRevision: revision, + ...(status === undefined ? {} : { status }), + }); +} + +function collectText(node: unknown): string[] { + if (node == null) { + return []; + } + if (Array.isArray(node)) { + return node.flatMap(item => collectText(item)); + } + if (typeof node !== 'object') { + return []; + } + const element = node as MockElement; + const output: string[] = []; + if (typeof element.props.text === 'string') { + output.push(element.props.text); + } + if (element.props.children !== undefined) { + output.push(...collectText(element.props.children)); + } + return output; +} + +function render(props: ReturnType, width: number) { + return renderActiveAgentsWidget(props, { + widgetName: 'ActiveAgentsWidget', + widgetId: 1, + width, + height: 100, + screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, + }) as unknown as { light: MockElement; dark: MockElement }; +} + +describe('renderActiveAgentsWidget', () => { + it('returns distinct light and dark layouts through the theme callback', () => { + const props = buildAndroidWidgetProps(snapshotFor([{ status: 'busy' }], 0), {}, translate); + const rep = render(props, 250); + + expect(rep.light).toBeDefined(); + expect(rep.dark).toBeDefined(); + expect(rep.light).not.toBe(rep.dark); + expect(rep.light.props.style?.backgroundColor).toBe('#FFFFFF'); + expect(rep.dark.props.style?.backgroundColor).toBe('#0B0F19'); + }); + + it('shows only the primary count at a small width', () => { + const props = buildAndroidWidgetProps( + snapshotFor([{ status: 'question' }, { status: 'busy' }, { status: 'busy' }], 0), + {}, + translate + ); + const rep = render(props, 120); + const text = collectText(rep.light); + + expect(text).toEqual(['1 Needs input']); + }); + + it('shows every non-zero count and the Open agents affordance at a wide width', () => { + const props = buildAndroidWidgetProps( + snapshotFor([{ status: 'question' }, { status: 'busy' }], 0), + {}, + translate + ); + const rep = render(props, 250); + const text = collectText(rep.light); + + expect(text).toEqual(['1 Needs input', '1 Running', 'Open agents']); + }); + + it('hides counts and shows expired copy for an expired snapshot', () => { + const props = buildAndroidWidgetProps( + { + ...snapshotFor([{ status: 'busy' }], 0), + status: 'expired', + running: 0, + needsInput: 0, + reconnecting: 0, + }, + {}, + translate + ); + const rep = render(props, 250); + const text = collectText(rep.light); + + expect(text).toEqual(['Status expired']); + }); + + it('labels the whole widget with the Open agents click action', () => { + const props = buildAndroidWidgetProps(snapshotFor([{ status: 'busy' }], 0), {}, translate); + const rep = render(props, 250); + + expect(rep.light.props.clickAction).toBe(OPEN_AGENTS_CLICK); + expect(rep.dark.props.clickAction).toBe(OPEN_AGENTS_CLICK); + }); +}); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx new file mode 100644 index 0000000000..234cac3780 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -0,0 +1,131 @@ +/* eslint-disable react-native/no-inline-styles -- react-native-android-widget primitives take style objects; NativeWind className is unavailable in the widget host */ + +'use no memo'; + +import { + FlexWidget, + type HexColor, + TextWidget, + type WidgetInfo, + type WidgetRepresentation, +} from 'react-native-android-widget'; + +import { type AndroidWidgetProps } from './widget-props'; + +export const WIDGET_NAME = 'ActiveAgentsWidget'; + +/** Custom click action; the task handler maps it to openGlanceableAgents(). */ +export const OPEN_AGENTS_CLICK = 'OPEN_AGENTS'; + +/** Below this width (dp) the widget shows only the primary count. */ +const COMPACT_MAX_WIDTH_DP = 150; + +type Palette = { background: HexColor; primary: HexColor; muted: HexColor }; + +const LIGHT: Palette = { + background: '#FFFFFF', + primary: '#111827', + muted: '#6B7280', +}; + +const DARK: Palette = { + background: '#0B0F19', + primary: '#F9FAFB', + muted: '#9CA3AF', +}; + +// This function is evaluated only through `renderActiveAgentsWidget` and the +// library's `buildWidgetTree`. Everything it references is explicit so the +// React Compiler is disabled ("use no memo") and the widget host can re-evaluate +// the source. Translated copy arrives through `props`; the English fallbacks +// below only render while the gallery placeholder has no snapshot props. + +function isCompact(info: WidgetInfo): boolean { + return info.width < COMPACT_MAX_WIDTH_DP; +} + +function compactText(props: AndroidWidgetProps): string { + if (props.primaryLabel === null) { + return props.statusLine ?? ''; + } + return `${props.primaryCount} ${props.primaryLabel}`; +} + +function countRows(props: AndroidWidgetProps, color: HexColor) { + return props.countLines.map(line => ( + + )); +} + +/** Compact widths show the primary count; wider cells show every non-zero count. */ +function renderPrimaryArea(props: AndroidWidgetProps, palette: Palette, compact: boolean) { + if (compact) { + return ( + + ); + } + if (props.countLines.length === 0) { + return null; + } + return countRows(props, palette.primary); +} + +function renderSurface(props: AndroidWidgetProps, palette: Palette, compact: boolean) { + return ( + + + {renderPrimaryArea(props, palette, compact)} + {!compact && props.statusLine !== null ? ( + + ) : null} + + {!compact && props.showOpenAgents ? ( + + ) : null} + + ); +} + +/** + * Distinct light and dark layouts through the library's theme callback. Narrow + * widths show only the primary count; wider cells show every non-zero count. + */ +export function renderActiveAgentsWidget( + props: AndroidWidgetProps, + info: WidgetInfo +): WidgetRepresentation { + const compact = isCompact(info); + return { + light: renderSurface(props, LIGHT, compact), + dark: renderSurface(props, DARK, compact), + }; +} diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts new file mode 100644 index 0000000000..c1119ec966 --- /dev/null +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -0,0 +1,246 @@ +/* eslint-disable max-lines -- one cohesive sink suite sharing the native + widget mock harness */ +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _resetAndroidSinkForTests, androidSink, getCurrentWidgetProps } from './android-sink'; +import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission'; +import { _resetAndroidPermissionAlertForTests } from './permission-alert'; + +const mocks = vi.hoisted(() => ({ + native: { + isPromotionCapable: vi.fn(() => true), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }, + requestWidgetUpdate: vi.fn(), +})); + +vi.mock('expo', () => ({ + requireOptionalNativeModule: () => mocks.native, +})); + +// permission-alert statically imports react-native; stub it so the pure +// node test graph never parses react-native's Flow sources. +vi.mock('react-native', () => ({ + Alert: { alert: (): void => undefined }, + Linking: { openSettings: (): void => undefined }, +})); + +// The sink imports the widget layout, whose primitives are unreachable under +// vitest; stub them so only the sink logic runs. +vi.mock('react-native-android-widget', () => ({ + FlexWidget: () => null, + TextWidget: () => null, + requestWidgetUpdate: (...args: unknown[]) => mocks.requestWidgetUpdate(...args), +})); + +const NOW = 1_750_000_000_000; +const CTX = { organizationId: null }; + +function snapshotFor( + sessions: { status: string }[], + revision = 0, + status?: GlanceableAgentsSnapshot['status'] +): GlanceableAgentsSnapshot { + return buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + previousRevision: revision, + ...(status === undefined ? {} : { status }), + }); +} + +async function flushAsync(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +/** A permission reader whose resolution the test controls by hand. */ +function deferredPermission(): { + promise: Promise; + resolve: (status: NotificationPermissionStatus) => void; +} { + let storedResolve: ((status: NotificationPermissionStatus) => void) | undefined = undefined; + const promise = new Promise(resolve => { + storedResolve = resolve; + }); + return { + promise, + resolve: status => { + storedResolve?.(status); + }, + }; +} + +beforeEach(() => { + _resetAndroidSinkForTests(); + _resetAndroidPermissionAlertForTests(); + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('granted')); + mocks.native.isPromotionCapable.mockReturnValue(true); + mocks.native.start.mockClear(); + mocks.native.update.mockClear(); + mocks.native.end.mockClear(); + mocks.requestWidgetUpdate.mockClear(); +}); + +afterEach(() => { + _setPermissionReaderForTests(null); + vi.useRealTimers(); +}); + +describe('androidSink start and update', () => { + it('starts once and updates the same notification id on a newer revision with promotion', async () => { + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 1), CTX); + await flushAsync(); + + expect(mocks.native.start).toHaveBeenCalledTimes(1); + expect(mocks.native.update).toHaveBeenCalledTimes(1); + expect(mocks.native.start).toHaveBeenCalledWith('Active agents', '1 Running', true); + expect(mocks.native.update).toHaveBeenCalledWith('Active agents', '1 Running', true); + }); + + it('passes promotion false when the device is not capable', async () => { + mocks.native.isPromotionCapable.mockReturnValue(false); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + + expect(mocks.native.start).toHaveBeenCalledTimes(1); + expect(mocks.native.start).toHaveBeenCalledWith(expect.any(String), expect.any(String), false); + }); + + it('does not start ongoing when notification permission is denied', async () => { + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('denied')); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + + expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); + }); + + it('does not post after endImmediate during an in-flight permission check', async () => { + const deferred = deferredPermission(); + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => deferred.promise); + + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + androidSink.endImmediate(); + deferred.resolve('granted'); + await flushAsync(); + + expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); + }); + + it('starts once permission is later granted for eligible work', async () => { + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('denied')); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + expect(mocks.native.start).not.toHaveBeenCalled(); + + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('granted')); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 1), CTX); + await flushAsync(); + expect(mocks.native.start).toHaveBeenCalledTimes(1); + }); + + it('discards an older revision without overwriting the latest', async () => { + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 4), CTX); + await flushAsync(); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 5), CTX); + await flushAsync(); + expect(mocks.native.start).toHaveBeenCalledTimes(1); + expect(mocks.native.update).toHaveBeenCalledTimes(1); + + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 4), CTX); + await flushAsync(); + expect(mocks.native.update).toHaveBeenCalledTimes(1); + }); + + it('never starts for a waiting snapshot', async () => { + androidSink.startOrUpdate(snapshotFor([], 0, 'waiting'), CTX); + await flushAsync(); + + expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); + }); +}); + +describe('androidSink widget publish and end', () => { + it('publishes the widget snapshot on every publish', () => { + androidSink.publish(snapshotFor([{ status: 'busy' }], 0)); + + expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(1); + expect(mocks.requestWidgetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ widgetName: 'ActiveAgentsWidget' }) + ); + expect(getCurrentWidgetProps()?.statusLine).toBeNull(); + expect(getCurrentWidgetProps()?.primaryCount).toBe(1); + }); + + it('blanks with privacy copy and dismisses the notification on end', async () => { + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + expect(mocks.native.start).toHaveBeenCalledTimes(1); + + androidSink.publish(snapshotFor([], 1, 'privacy')); + expect(getCurrentWidgetProps()?.statusLine).toBe('Agents hidden'); + expect(getCurrentWidgetProps()?.countLines).toEqual([]); + expect(mocks.native.update).toHaveBeenCalledWith('Active agents', 'Agents hidden', true); + + androidSink.endImmediate(); + expect(mocks.native.end).toHaveBeenCalledTimes(1); + }); + + it('does not update the notification from publish before it has started', () => { + androidSink.publish(snapshotFor([], 1, 'empty')); + + expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); + }); + + it('keeps the widget truthful after end', () => { + androidSink.publish(snapshotFor([{ status: 'busy' }], 0)); + expect(getCurrentWidgetProps()).not.toBeNull(); + + androidSink.endImmediate(); + expect(mocks.native.end).toHaveBeenCalledTimes(1); + expect(getCurrentWidgetProps()).not.toBeNull(); + expect(getCurrentWidgetProps()?.primaryCount).toBe(1); + }); + + it('schedules a single future redraw at expiresAt with expired copy', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const snapshot = snapshotFor([{ status: 'busy' }], 0); + + androidSink.publish(snapshot); + expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(28_800_000); + expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(2); + + const secondCall = mocks.requestWidgetUpdate.mock.calls[1]?.[0] as + | { renderWidget?: unknown } + | undefined; + expect(typeof secondCall?.renderWidget).toBe('function'); + + expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired'); + expect(getCurrentWidgetProps()?.countLines).toEqual([]); + expect(getCurrentWidgetProps()?.primaryCount).toBe(0); + expect(getCurrentWidgetProps()?.showOpenAgents).toBe(false); + }); +}); diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts new file mode 100644 index 0000000000..a5ee286d5a --- /dev/null +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -0,0 +1,199 @@ +import { + type GlanceableAgentsSnapshot, + isEligibleGlanceableWork, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { requestWidgetUpdate } from 'react-native-android-widget'; + +import { i18n } from '@/i18n'; +import { type GlanceableSink, type GlanceableSinkContext } from '@/lib/glanceable/sink-registry'; + +import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget'; +import { + end as endLiveUpdate, + start as startLiveUpdate, + update as updateLiveUpdate, +} from './live-update'; +import { isNotificationPermissionGranted } from './permission'; +import { showAndroidPermissionAlertOnce } from './permission-alert'; +import { + type AndroidWidgetProps, + buildAndroidWidgetProps, + buildExpiredWidgetProps, + buildOngoingNotificationText, +} from './widget-props'; + +/** + * Android sink: one ongoing notification plus the resizable Home widget. The + * widget renders from the last published snapshot; the notification starts on + * the first eligible emit (after a permission check) and updates one fixed id. + * Ended notifications never clear the widget so the Home surface stays truthful. + */ + +type TimerHandle = ReturnType; + +const NOTIFICATION_TITLE_KEY = 'glanceable.channelName'; + +function translate(key: string): string { + return i18n.t(key); +} + +let lastWidgetProps: AndroidWidgetProps | null = null; +let notificationActive = false; +let revision = 0; +let pending: { snapshot: GlanceableAgentsSnapshot; ctx: GlanceableSinkContext } | null = null; +let expiryTimer: TimerHandle | null = null; +let startEpoch = 0; + +/** The last published widget props; the task handler renders a fresh redraw from it. */ +export function getCurrentWidgetProps(): AndroidWidgetProps | null { + return lastWidgetProps; +} + +function renderWidgetNow(props: AndroidWidgetProps): void { + void requestWidgetUpdate({ + widgetName: WIDGET_NAME, + renderWidget: info => renderActiveAgentsWidget(props, info), + }); +} + +function clearExpiryTimer(): void { + if (expiryTimer !== null) { + clearTimeout(expiryTimer); + expiryTimer = null; + } +} + +/** One future redraw at expiresAt (no per-minute timer) that hides the counts. */ +function scheduleExpiryRedraw(snapshot: GlanceableAgentsSnapshot): void { + clearExpiryTimer(); + const delay = Date.parse(snapshot.expiresAt) - Date.now(); + if (delay <= 0) { + return; + } + const expiredProps = buildExpiredWidgetProps(snapshot, translate); + expiryTimer = setTimeout(() => { + expiryTimer = null; + lastWidgetProps = expiredProps; + renderWidgetNow(expiredProps); + }, delay); +} + +/** + * Start the ongoing notification once permission is granted. Permission-denied + * emits record the latest eligible snapshot so a later gesture can restart it. + */ +async function tryStartOrUpdate( + snapshot: GlanceableAgentsSnapshot, + ctx: GlanceableSinkContext +): Promise { + if (!isEligibleGlanceableWork(snapshot)) { + pending = null; + return; + } + if (notificationActive && snapshot.revision <= revision) { + return; + } + const title = translate(NOTIFICATION_TITLE_KEY); + const text = buildOngoingNotificationText(snapshot, {}, translate); + + if (notificationActive) { + updateLiveUpdate(title, text); + revision = snapshot.revision; + return; + } + + const epoch = startEpoch; + const granted = await isNotificationPermissionGranted(); + if (epoch !== startEpoch) { + return; + } + if (granted) { + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- a concurrent start/retry can set notificationActive while awaiting permission + if (notificationActive) { + if (snapshot.revision > revision) { + updateLiveUpdate(title, text); + revision = snapshot.revision; + } + return; + } + startLiveUpdate(title, text); + notificationActive = true; + revision = snapshot.revision; + pending = null; + return; + } + pending = { snapshot, ctx }; +} + +/** Retry a pending start after permission turns granted. Caller owns the check. */ +export function retryPendingStart(): void { + const p = pending; + if (p === null || notificationActive || !isEligibleGlanceableWork(p.snapshot)) { + return; + } + const title = translate(NOTIFICATION_TITLE_KEY); + startLiveUpdate(title, buildOngoingNotificationText(p.snapshot, {}, translate)); + notificationActive = true; + revision = p.snapshot.revision; + pending = null; +} + +/** + * A widget tap: when the ongoing cannot start (denied) and work is pending, + * show the Open Settings alert once. When permission is granted, start at once. + */ +export async function handleWidgetOpenTap(): Promise { + const granted = await isNotificationPermissionGranted(); + if (granted) { + retryPendingStart(); + return; + } + if (pending !== null) { + showAndroidPermissionAlertOnce(); + } +} + +export const androidSink: GlanceableSink = { + publish(snapshot) { + const props = buildAndroidWidgetProps(snapshot, {}, translate); + lastWidgetProps = props; + renderWidgetNow(props); + scheduleExpiryRedraw(snapshot); + if (!isEligibleGlanceableWork(snapshot)) { + pending = null; + } + // Mirror the newest revision onto an already-started notification so the + // empty/stale/privacy copy shows during the terminal window before end. + if (notificationActive && snapshot.revision > revision) { + updateLiveUpdate( + translate(NOTIFICATION_TITLE_KEY), + buildOngoingNotificationText(snapshot, {}, translate) + ); + revision = snapshot.revision; + } + }, + + startOrUpdate(snapshot, ctx) { + void tryStartOrUpdate(snapshot, ctx); + }, + + endImmediate() { + clearExpiryTimer(); + endLiveUpdate(); + notificationActive = false; + revision = 0; + pending = null; + startEpoch += 1; + // Widget props intentionally kept: the Home widget stays truthful. + }, +}; + +/** Test-only: drop all sink state between cases. */ +export function _resetAndroidSinkForTests(): void { + lastWidgetProps = null; + notificationActive = false; + revision = 0; + pending = null; + startEpoch += 1; + clearExpiryTimer(); +} diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts new file mode 100644 index 0000000000..fb3b059fcb --- /dev/null +++ b/apps/mobile/src/glanceable-android/live-update.ts @@ -0,0 +1,36 @@ +import { requireOptionalNativeModule } from 'expo'; + +/** + * JS wrapper over the local `ActiveAgentsLiveUpdate` native module. The native + * side owns the notification id, the channel, and the promotion gate; the JS + * side owns the translated copy and the revision guard (see android-sink). + */ + +export type LiveUpdateNativeModule = { + isPromotionCapable(): boolean; + start(title: string, text: string, promotion: boolean): void; + update(title: string, text: string, promotion: boolean): void; + end(): void; +}; + +const nativeModule = requireOptionalNativeModule('ActiveAgentsLiveUpdate'); + +/** + * API 36.1+ promotion capability: SDK_INT_FULL >= 36_001_000 and + * NotificationManager.canPostPromotedNotifications(). Mirrors the native gate. + */ +export function isPromotionCapable(): boolean { + return nativeModule?.isPromotionCapable() ?? false; +} + +export function start(title: string, text: string): void { + nativeModule?.start(title, text, isPromotionCapable()); +} + +export function update(title: string, text: string): void { + nativeModule?.update(title, text, isPromotionCapable()); +} + +export function end(): void { + nativeModule?.end(); +} diff --git a/apps/mobile/src/glanceable-android/permission-alert.ts b/apps/mobile/src/glanceable-android/permission-alert.ts new file mode 100644 index 0000000000..be5e7aae76 --- /dev/null +++ b/apps/mobile/src/glanceable-android/permission-alert.ts @@ -0,0 +1,27 @@ +import { Alert, Linking } from 'react-native'; + +import { i18n } from '@/i18n'; + +/** + * The one in-app Open Settings alert for a denied Android notification + * permission. Shown at most once per missing permission, and only from a widget + * tap that cannot start the ongoing notification — never on publisher start. + */ + +let alertShown = false; + +export function showAndroidPermissionAlertOnce(): void { + if (alertShown) { + return; + } + alertShown = true; + Alert.alert(i18n.t('notifications.disabledTitle'), i18n.t('notifications.disabledMessage'), [ + { text: i18n.t('common.cancel'), style: 'cancel' }, + { text: i18n.t('common.openSettings'), onPress: () => void Linking.openSettings() }, + ]); +} + +/** Test-only: drop the once-per-missing-permission latch between cases. */ +export function _resetAndroidPermissionAlertForTests(): void { + alertShown = false; +} diff --git a/apps/mobile/src/glanceable-android/permission.ts b/apps/mobile/src/glanceable-android/permission.ts new file mode 100644 index 0000000000..c0f266fc79 --- /dev/null +++ b/apps/mobile/src/glanceable-android/permission.ts @@ -0,0 +1,33 @@ +/** + * Notification-permission reader for the Android ongoing surface. The default + * reads expo-notifications lazily so pure test suites never load React Native; + * tests inject a synchronous reader instead. + */ + +export type NotificationPermissionStatus = 'granted' | 'denied' | 'undetermined'; + +type PermissionReader = () => Promise; + +let permissionReader: PermissionReader | null = null; + +async function defaultPermissionReader(): Promise { + // Lazy require keeps expo-notifications (→ expo-modules-core → RN) out of the + // unit-test graph, matching the persist/deep-link-launch pattern. + // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- lazy native load + const { getNotificationPermissionStatus } = require('@/lib/notifications') as { + getNotificationPermissionStatus: () => Promise; + }; + const status = await getNotificationPermissionStatus(); + return status; +} + +export async function isNotificationPermissionGranted(): Promise { + const reader = permissionReader ?? defaultPermissionReader; + const status = await reader(); + return status === 'granted'; +} + +/** Test-only: replace the reader so permission state is controllable per case. */ +export function _setPermissionReaderForTests(reader: PermissionReader | null): void { + permissionReader = reader; +} diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts new file mode 100644 index 0000000000..2cb8e13331 --- /dev/null +++ b/apps/mobile/src/glanceable-android/register.ts @@ -0,0 +1,36 @@ +import { + registerWidgetTaskHandler, + type WidgetTaskHandlerProps, +} from 'react-native-android-widget'; + +import { i18n } from '@/i18n'; +import { registerGlanceableSink } from '@/lib/glanceable/sink-registry'; +import { openGlanceableAgents } from '@/lib/glanceable/open-agents'; + +import { OPEN_AGENTS_CLICK, renderActiveAgentsWidget } from './active-agents-widget'; +import { androidSink, getCurrentWidgetProps, handleWidgetOpenTap } from './android-sink'; +import { buildGenericWidgetProps } from './widget-props'; + +// Register the Android sink at import time. The main-app import of the local +// live-update module loads this file, so the sink subscribes before any widget +// render. No React dependency here: the publisher is plain state. +registerGlanceableSink(androidSink); + +function translate(key: string): string { + return i18n.t(key); +} + +registerWidgetTaskHandler(async (task: WidgetTaskHandlerProps) => { + const { widgetInfo, widgetAction, clickAction, renderWidget } = task; + + if (widgetAction === 'WIDGET_CLICK') { + if (clickAction === OPEN_AGENTS_CLICK) { + openGlanceableAgents(); + await handleWidgetOpenTap(); + } + return; + } + + const props = getCurrentWidgetProps() ?? buildGenericWidgetProps(translate); + renderWidget(renderActiveAgentsWidget(props, widgetInfo)); +}); diff --git a/apps/mobile/src/glanceable-android/widget-config.json b/apps/mobile/src/glanceable-android/widget-config.json new file mode 100644 index 0000000000..39be56193c --- /dev/null +++ b/apps/mobile/src/glanceable-android/widget-config.json @@ -0,0 +1,16 @@ +{ + "widgets": [ + { + "name": "ActiveAgentsWidget", + "label": "Active agents", + "description": "Shows your active agents at a glance.", + "minWidth": "110dp", + "minHeight": "40dp", + "targetCellWidth": 2, + "targetCellHeight": 1, + "maxResizeWidth": "360dp", + "maxResizeHeight": "120dp", + "resizeMode": "horizontal|vertical" + } + ] +} diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts new file mode 100644 index 0000000000..eedc335871 --- /dev/null +++ b/apps/mobile/src/glanceable-android/widget-props.test.ts @@ -0,0 +1,115 @@ +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { describe, expect, it } from 'vitest'; + +import { buildAndroidWidgetProps, buildOngoingNotificationText } from './widget-props'; + +const NOW = 1_750_000_000_000; + +const translate = (key: string): string => key; + +function snapshotFor( + sessions: { status: string }[], + revision = 0, + status?: GlanceableAgentsSnapshot['status'] +): GlanceableAgentsSnapshot { + return buildGlanceableSnapshot({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + previousRevision: revision, + ...(status === undefined ? {} : { status }), + }); +} + +describe('buildAndroidWidgetProps', () => { + it('ranks the compact primary count as needs-input, then reconnecting, then running', () => { + const props = buildAndroidWidgetProps( + snapshotFor( + [{ status: 'busy' }, { status: 'busy' }, { status: 'retry' }, { status: 'question' }], + 0 + ), + {}, + translate + ); + expect(props.primaryLabel).toBe('glanceable.needsInput'); + expect(props.primaryCount).toBe(1); + expect(props.countLines.map(line => line.label)).toEqual([ + 'glanceable.needsInput', + 'glanceable.reconnecting', + 'glanceable.running', + ]); + }); + + it('applies the locked copy matrix per status', () => { + const cases: [ + GlanceableAgentsSnapshot['status'], + { status: string }[], + string, + number, + boolean, + ][] = [ + ['empty', [], 'glanceable.empty', 0, false], + ['stale', [{ status: 'busy' }], 'glanceable.stale', 1, true], + ['expired', [], 'glanceable.expired', 0, false], + ['signed_out', [], 'glanceable.signedOut', 0, false], + ['privacy', [], 'glanceable.privacy', 0, false], + ]; + for (const [status, sessions, statusLine, counts, showOpenAgents] of cases) { + const props = buildAndroidWidgetProps(snapshotFor(sessions, 0, status), {}, translate); + expect(props.statusLine).toBe(statusLine); + expect(props.countLines).toHaveLength(counts); + expect(props.showOpenAgents).toBe(showOpenAgents); + } + }); + + it('carries no title, organization name, or raw id into the widget payload', () => { + const snapshot = buildGlanceableSnapshot({ + sessions: [{ status: 'busy' }], + userId: 'user-9f3a-leak', + organizationId: 'org-acme-7-leak', + now: NOW, + previousEligibleStartedAt: new Date(NOW - 60_000).toISOString(), + }); + + const props = buildAndroidWidgetProps(snapshot, {}, translate); + const json = JSON.stringify(props); + + expect(Object.keys(props).toSorted()).toEqual([ + 'accessibilityLabel', + 'countLines', + 'openAgentsLabel', + 'primaryCount', + 'primaryLabel', + 'showOpenAgents', + 'statusLine', + ]); + expect(json).not.toContain('user-9f3a-leak'); + expect(json).not.toContain('org-acme-7-leak'); + expect(json).not.toContain(snapshot.scopeKey); + expect(json).not.toContain(snapshot.updatedAt); + expect(json).not.toContain('revision'); + expect(json).not.toContain('title'); + }); +}); + +describe('buildOngoingNotificationText', () => { + it('lists ranked counts for happy and stale, otherwise the locked copy', () => { + const happy = snapshotFor([{ status: 'busy' }, { status: 'question' }], 0); + expect(buildOngoingNotificationText(happy, {}, translate)).toBe( + '1 glanceable.needsInput, 1 glanceable.running' + ); + + const stale = snapshotFor([{ status: 'retry' }], 0, 'stale'); + expect(buildOngoingNotificationText(stale, {}, translate)).toBe('1 glanceable.reconnecting'); + + const empty = snapshotFor([], 0, 'empty'); + expect(buildOngoingNotificationText(empty, {}, translate)).toBe('glanceable.empty'); + + const privacy = snapshotFor([], 0, 'privacy'); + expect(buildOngoingNotificationText(privacy, {}, translate)).toBe('glanceable.privacy'); + }); +}); diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts new file mode 100644 index 0000000000..059fd6832d --- /dev/null +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -0,0 +1,114 @@ +import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot'; + +import { + glanceableCountLines, + glanceableSpokenLabelKeys, + glanceableStatusCopyKey, + type GlanceableSurfaceFlags, + primaryGlanceableCount, + resolveGlanceableStatus, +} from '@/lib/glanceable/presentation'; + +/** One translated count line for an Android surface. */ +export type AndroidWidgetCount = { label: string; count: number }; + +/** + * The props the Android widget renders. The builder below is the only producer, + * so a title, organization name, account id, or raw session id can never reach + * the widget host. Android has no elapsed timer, so there is no elapsed anchor. + */ +export type AndroidWidgetProps = { + /** Translated locked copy; null while counts show (happy). Stale carries both. */ + statusLine: string | null; + /** Non-zero count lines in rank order (needs-input, reconnecting, running). */ + countLines: AndroidWidgetCount[]; + /** Top-ranked count label for compact widths; null when no eligible work. */ + primaryLabel: string | null; + /** Top-ranked count value for compact widths; 0 when no eligible work. */ + primaryCount: number; + /** Translated "Open agents" affordance. */ + openAgentsLabel: string; + /** True for happy and stale — the only statuses that show counts. */ + showOpenAgents: boolean; + /** Spoken label: status words, counts, then Open agents. Never a title or id. */ + accessibilityLabel: string; +}; + +/** Build the Android widget props from a snapshot, surface flags, and a translator. */ +export function buildAndroidWidgetProps( + snapshot: GlanceableAgentsSnapshot, + flags: GlanceableSurfaceFlags, + translate: (key: string) => string +): AndroidWidgetProps { + const status = resolveGlanceableStatus(snapshot, flags); + const statusKey = glanceableStatusCopyKey(snapshot, flags); + const primary = primaryGlanceableCount(snapshot); + + return { + statusLine: statusKey === null ? null : translate(statusKey), + countLines: glanceableCountLines(snapshot).map(line => ({ + label: translate(line.key), + count: line.count, + })), + primaryLabel: primary === null ? null : translate(primary.key), + primaryCount: primary === null ? 0 : primary.count, + openAgentsLabel: translate('glanceable.openAgents'), + showOpenAgents: status === 'happy' || status === 'stale', + accessibilityLabel: glanceableSpokenLabelKeys(snapshot, flags) + .map(key => translate(key)) + .join(', '), + }; +} + +/** Zero-count expired props: the single future redraw hides counts at expiresAt. */ +export function buildExpiredWidgetProps( + snapshot: GlanceableAgentsSnapshot, + translate: (key: string) => string +): AndroidWidgetProps { + return buildAndroidWidgetProps( + { + ...snapshot, + status: 'expired', + running: 0, + needsInput: 0, + reconnecting: 0, + eligibleStartedAt: null, + }, + {}, + translate + ); +} + +/** Gallery placeholder: empty copy and no counts, with no snapshot behind it. */ +export function buildGenericWidgetProps(translate: (key: string) => string): AndroidWidgetProps { + const empty = translate('glanceable.empty'); + return { + statusLine: empty, + countLines: [], + primaryLabel: null, + primaryCount: 0, + openAgentsLabel: translate('glanceable.openAgents'), + showOpenAgents: false, + accessibilityLabel: empty, + }; +} + +/** + * Single-line summary for the ongoing notification: ranked counts for happy and + * stale, otherwise the locked status copy. Built only from translated keys, so it + * never leaks a title, organization name, or id. + */ +export function buildOngoingNotificationText( + snapshot: GlanceableAgentsSnapshot, + flags: GlanceableSurfaceFlags, + translate: (key: string) => string +): string { + const status = resolveGlanceableStatus(snapshot, flags); + if (status === 'happy' || status === 'stale') { + const lines = glanceableCountLines(snapshot); + if (lines.length > 0) { + return lines.map(line => `${line.count} ${translate(line.key)}`).join(', '); + } + } + return translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty'); +} diff --git a/apps/mobile/vitest.pure.config.ts b/apps/mobile/vitest.pure.config.ts index e3421f7196..d367647a9b 100644 --- a/apps/mobile/vitest.pure.config.ts +++ b/apps/mobile/vitest.pure.config.ts @@ -29,6 +29,7 @@ export default defineProject({ 'src/lib/apple-iap/**/*.test.tsx', 'src/lib/glanceable/**/*.test.ts', 'src/glanceable-ios/**/*.test.ts', + 'src/glanceable-android/**/*.test.ts', 'src/lib/hooks/**/*.test.ts', 'src/lib/kilo-pass/**/*.test.ts', 'src/lib/kilo-pass/**/*.test.tsx', From 6b17fcd4f47573670021f4086cad07e270dcb263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 02:29:30 +0200 Subject: [PATCH 2/6] fix(glanceable): harden Android ongoing promotion, alert, and tap Gate the promoted-ongoing check behind SDK_INT >= 36 before reading SDK_INT_FULL. Replace the custom widget click action with an OPEN_URI deep link to the agents tab. Show the notification permission alert on app foreground and retry a pending start when permission is granted. Dismiss a leftover ongoing notification for an ineligible snapshot. Drop four dead exports and register the Android widget task entry with knip. --- apps/mobile/knip.json | 2 +- .../ActiveAgentsLiveUpdateModule.kt | 4 +- .../expo-module.config.json | 2 +- .../active-agents-widget.test.ts | 11 +++-- .../active-agents-widget.tsx | 6 +-- .../glanceable-android/android-sink.test.ts | 47 ++++++++++++++++++- .../src/glanceable-android/android-sink.ts | 22 +++++---- .../src/glanceable-android/live-update.ts | 4 +- .../mobile/src/glanceable-android/register.ts | 25 +++++----- .../src/glanceable-android/widget-props.ts | 2 +- 10 files changed, 89 insertions(+), 36 deletions(-) diff --git a/apps/mobile/knip.json b/apps/mobile/knip.json index 1a0c0edfb2..933dcd8923 100644 --- a/apps/mobile/knip.json +++ b/apps/mobile/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["src/app/**/*.{ts,tsx}"], + "entry": ["src/app/**/*.{ts,tsx}", "src/glanceable-android/register.ts"], "project": ["src/**/*.{ts,tsx}"], "ignoreDependencies": [ "expo-updates", diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 9444a5fea8..73326f3ddf 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -46,7 +46,9 @@ class ActiveAgentsLiveUpdateModule : Module() { context.resources.getIdentifier("notification_icon", "drawable", context.packageName) private fun isPromotionCapable(): Boolean = - Build.VERSION.SDK_INT_FULL >= 36_001_000 && notificationManager.canPostPromotedNotifications() + Build.VERSION.SDK_INT >= 36 && + Build.VERSION.SDK_INT_FULL >= 36_001_000 && + notificationManager.canPostPromotedNotifications() private fun ensureChannel(title: String) { if (Build.VERSION.SDK_INT < 26) { diff --git a/apps/mobile/modules/active-agents-live-update/expo-module.config.json b/apps/mobile/modules/active-agents-live-update/expo-module.config.json index 6c19feec99..5c980694d2 100644 --- a/apps/mobile/modules/active-agents-live-update/expo-module.config.json +++ b/apps/mobile/modules/active-agents-live-update/expo-module.config.json @@ -6,4 +6,4 @@ "android": { "modules": ["com.kilocode.activeagentsliveupdate.ActiveAgentsLiveUpdateModule"] } -} \ No newline at end of file +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index 12887b7ca8..555cc6a791 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -4,7 +4,7 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { describe, expect, it, vi } from 'vitest'; -import { OPEN_AGENTS_CLICK, renderActiveAgentsWidget } from './active-agents-widget'; +import { renderActiveAgentsWidget } from './active-agents-widget'; import { buildAndroidWidgetProps } from './widget-props'; // Stub the widget primitives so the layout functions return inspectable trees @@ -22,6 +22,7 @@ type MockElement = { props: { text?: string; clickAction?: string; + clickActionData?: { uri?: string }; style?: { backgroundColor?: string }; children?: unknown; }; @@ -140,11 +141,13 @@ describe('renderActiveAgentsWidget', () => { expect(text).toEqual(['Status expired']); }); - it('labels the whole widget with the Open agents click action', () => { + it('labels the whole widget with the Open agents deep-link click action', () => { const props = buildAndroidWidgetProps(snapshotFor([{ status: 'busy' }], 0), {}, translate); const rep = render(props, 250); - expect(rep.light.props.clickAction).toBe(OPEN_AGENTS_CLICK); - expect(rep.dark.props.clickAction).toBe(OPEN_AGENTS_CLICK); + expect(rep.light.props.clickAction).toBe('OPEN_URI'); + expect(rep.light.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' }); + expect(rep.dark.props.clickAction).toBe('OPEN_URI'); + expect(rep.dark.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' }); }); }); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx index 234cac3780..5de7dd7766 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -14,9 +14,6 @@ import { type AndroidWidgetProps } from './widget-props'; export const WIDGET_NAME = 'ActiveAgentsWidget'; -/** Custom click action; the task handler maps it to openGlanceableAgents(). */ -export const OPEN_AGENTS_CLICK = 'OPEN_AGENTS'; - /** Below this width (dp) the widget shows only the primary count. */ const COMPACT_MAX_WIDTH_DP = 150; @@ -82,7 +79,8 @@ function renderPrimaryArea(props: AndroidWidgetProps, palette: Palette, compact: function renderSurface(props: AndroidWidgetProps, palette: Palette, compact: boolean) { return ( ({ end: vi.fn(), }, requestWidgetUpdate: vi.fn(), + alert: vi.fn(), })); vi.mock('expo', () => ({ @@ -26,7 +32,7 @@ vi.mock('expo', () => ({ // permission-alert statically imports react-native; stub it so the pure // node test graph never parses react-native's Flow sources. vi.mock('react-native', () => ({ - Alert: { alert: (): void => undefined }, + Alert: { alert: (...args: unknown[]) => mocks.alert(...args) }, Linking: { openSettings: (): void => undefined }, })); @@ -89,6 +95,7 @@ beforeEach(() => { mocks.native.update.mockClear(); mocks.native.end.mockClear(); mocks.requestWidgetUpdate.mockClear(); + mocks.alert.mockClear(); }); afterEach(() => { @@ -212,6 +219,12 @@ describe('androidSink widget publish and end', () => { expect(mocks.native.update).not.toHaveBeenCalled(); }); + it('dismisses a leftover native notification for an ineligible snapshot', () => { + androidSink.publish(snapshotFor([], 1, 'empty')); + + expect(mocks.native.end).toHaveBeenCalledTimes(1); + }); + it('keeps the widget truthful after end', () => { androidSink.publish(snapshotFor([{ status: 'busy' }], 0)); expect(getCurrentWidgetProps()).not.toBeNull(); @@ -244,3 +257,33 @@ describe('androidSink widget publish and end', () => { expect(getCurrentWidgetProps()?.showOpenAgents).toBe(false); }); }); + +describe('handleAppStateActive permission alert', () => { + it('shows the permission alert once for denied work when the app foregrounds', async () => { + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('denied')); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + expect(mocks.native.start).not.toHaveBeenCalled(); + + await handleAppStateActive(); + expect(mocks.alert).toHaveBeenCalledTimes(1); + + await handleAppStateActive(); + expect(mocks.alert).toHaveBeenCalledTimes(1); + }); + + it('retries the pending start when permission is granted on foreground', async () => { + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('denied')); + androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + await flushAsync(); + expect(mocks.native.start).not.toHaveBeenCalled(); + + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => Promise.resolve('granted')); + await handleAppStateActive(); + expect(mocks.native.start).toHaveBeenCalledTimes(1); + expect(mocks.alert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index a5ee286d5a..a38084b1a4 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -126,7 +126,7 @@ async function tryStartOrUpdate( } /** Retry a pending start after permission turns granted. Caller owns the check. */ -export function retryPendingStart(): void { +function retryPendingStart(): void { const p = pending; if (p === null || notificationActive || !isEligibleGlanceableWork(p.snapshot)) { return; @@ -139,18 +139,19 @@ export function retryPendingStart(): void { } /** - * A widget tap: when the ongoing cannot start (denied) and work is pending, + * App foreground: when the ongoing cannot start (denied) and work is pending, * show the Open Settings alert once. When permission is granted, start at once. + * The alert needs a foreground Activity, so this never runs on the headless path. */ -export async function handleWidgetOpenTap(): Promise { - const granted = await isNotificationPermissionGranted(); - if (granted) { - retryPendingStart(); +export async function handleAppStateActive(): Promise { + if (pending === null) { return; } - if (pending !== null) { - showAndroidPermissionAlertOnce(); + if (await isNotificationPermissionGranted()) { + retryPendingStart(); + return; } + showAndroidPermissionAlertOnce(); } export const androidSink: GlanceableSink = { @@ -161,6 +162,11 @@ export const androidSink: GlanceableSink = { scheduleExpiryRedraw(snapshot); if (!isEligibleGlanceableWork(snapshot)) { pending = null; + if (!notificationActive) { + // Dismiss a leftover native notification from a previous process. `end` + // cancels the fixed id, which is a no-op when nothing is posted. + endLiveUpdate(); + } } // Mirror the newest revision onto an already-started notification so the // empty/stale/privacy copy shows during the terminal window before end. diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts index fb3b059fcb..4d6028fdcd 100644 --- a/apps/mobile/src/glanceable-android/live-update.ts +++ b/apps/mobile/src/glanceable-android/live-update.ts @@ -6,7 +6,7 @@ import { requireOptionalNativeModule } from 'expo'; * side owns the translated copy and the revision guard (see android-sink). */ -export type LiveUpdateNativeModule = { +type LiveUpdateNativeModule = { isPromotionCapable(): boolean; start(title: string, text: string, promotion: boolean): void; update(title: string, text: string, promotion: boolean): void; @@ -19,7 +19,7 @@ const nativeModule = requireOptionalNativeModule('Active * API 36.1+ promotion capability: SDK_INT_FULL >= 36_001_000 and * NotificationManager.canPostPromotedNotifications(). Mirrors the native gate. */ -export function isPromotionCapable(): boolean { +function isPromotionCapable(): boolean { return nativeModule?.isPromotionCapable() ?? false; } diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts index 2cb8e13331..dfd2ba242b 100644 --- a/apps/mobile/src/glanceable-android/register.ts +++ b/apps/mobile/src/glanceable-android/register.ts @@ -1,3 +1,4 @@ +import { AppState } from 'react-native'; import { registerWidgetTaskHandler, type WidgetTaskHandlerProps, @@ -5,10 +6,9 @@ import { import { i18n } from '@/i18n'; import { registerGlanceableSink } from '@/lib/glanceable/sink-registry'; -import { openGlanceableAgents } from '@/lib/glanceable/open-agents'; -import { OPEN_AGENTS_CLICK, renderActiveAgentsWidget } from './active-agents-widget'; -import { androidSink, getCurrentWidgetProps, handleWidgetOpenTap } from './android-sink'; +import { renderActiveAgentsWidget } from './active-agents-widget'; +import { androidSink, getCurrentWidgetProps, handleAppStateActive } from './android-sink'; import { buildGenericWidgetProps } from './widget-props'; // Register the Android sink at import time. The main-app import of the local @@ -16,20 +16,21 @@ import { buildGenericWidgetProps } from './widget-props'; // render. No React dependency here: the publisher is plain state. registerGlanceableSink(androidSink); +// The permission alert needs a foreground Activity; RN Android's AlertModule +// no-ops in headless JS. Show it when the app returns to the foreground instead. +AppState.addEventListener('change', state => { + if (state === 'active') { + void handleAppStateActive(); + } +}); + function translate(key: string): string { return i18n.t(key); } +// eslint-disable-next-line require-await, @typescript-eslint/require-await -- react-native-android-widget requires an async handler; the render path is synchronous registerWidgetTaskHandler(async (task: WidgetTaskHandlerProps) => { - const { widgetInfo, widgetAction, clickAction, renderWidget } = task; - - if (widgetAction === 'WIDGET_CLICK') { - if (clickAction === OPEN_AGENTS_CLICK) { - openGlanceableAgents(); - await handleWidgetOpenTap(); - } - return; - } + const { widgetInfo, renderWidget } = task; const props = getCurrentWidgetProps() ?? buildGenericWidgetProps(translate); renderWidget(renderActiveAgentsWidget(props, widgetInfo)); diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts index 059fd6832d..79674d515c 100644 --- a/apps/mobile/src/glanceable-android/widget-props.ts +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -10,7 +10,7 @@ import { } from '@/lib/glanceable/presentation'; /** One translated count line for an Android surface. */ -export type AndroidWidgetCount = { label: string; count: number }; +type AndroidWidgetCount = { label: string; count: number }; /** * The props the Android widget renders. The builder below is the only producer, From 190654f7b1f8b23beedd53696d7cc5769699ebbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 06:00:32 +0200 Subject: [PATCH 3/6] fix(mobile): restore Android widgets and notification taps --- .../ActiveAgentsLiveUpdateModule.kt | 19 ++ .../src/glanceable-android/register.test.ts | 218 ++++++++++++++++++ .../mobile/src/glanceable-android/register.ts | 28 ++- 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/glanceable-android/register.test.ts diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 73326f3ddf..70a5bba9a5 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -3,7 +3,10 @@ package com.kilocode.activeagentsliveupdate import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Build import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -75,11 +78,25 @@ class ActiveAgentsLiveUpdateModule : Module() { @Suppress("DEPRECATION") private fun legacyBuilder(): Notification.Builder = Notification.Builder(context) + /** A PendingIntent that deep-links the app to the Open agents route. */ + private fun openAgentsPendingIntent(): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(OPEN_AGENTS_DEEP_LINK)).apply { + setPackage(context.packageName) + } + return PendingIntent.getActivity( + context, + OPEN_AGENTS_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + private fun post(title: String, text: String, promotion: Boolean) { val builder = newBuilder(title) .setSmallIcon(smallIconId()) .setContentTitle(title) .setContentText(text) + .setContentIntent(openAgentsPendingIntent()) .setOngoing(true) .setOnlyAlertOnce(true) .setSound(null) @@ -102,5 +119,7 @@ class ActiveAgentsLiveUpdateModule : Module() { private companion object { const val CHANNEL_ID = "active-agents" const val NOTIFICATION_ID = 1001 + const val OPEN_AGENTS_DEEP_LINK = "kiloapp:///cloud/sessions" + const val OPEN_AGENTS_REQUEST_CODE = 1002 } } \ No newline at end of file diff --git a/apps/mobile/src/glanceable-android/register.test.ts b/apps/mobile/src/glanceable-android/register.test.ts new file mode 100644 index 0000000000..823fe6e73a --- /dev/null +++ b/apps/mobile/src/glanceable-android/register.test.ts @@ -0,0 +1,218 @@ +import { + buildGlanceableSnapshot, + type GlanceableAgentsSnapshot, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; +import { isValidElement, type ReactNode } from 'react'; +import { type WidgetRepresentation, type WidgetTaskHandler } from 'react-native-android-widget'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + registerWidgetTaskHandler: vi.fn<(handler: WidgetTaskHandler) => void>(), +})); + +vi.mock('expo', () => ({ requireOptionalNativeModule: () => null })); +vi.mock('react-native', () => ({ + AppState: { addEventListener: vi.fn() }, + Alert: { alert: vi.fn() }, + Linking: { openSettings: vi.fn() }, +})); +vi.mock('react-native-android-widget', () => ({ + registerWidgetTaskHandler: mocks.registerWidgetTaskHandler, + requestWidgetUpdate: vi.fn().mockResolvedValue(undefined), + FlexWidget: () => null, + TextWidget: () => null, +})); + +const NOW = 1_750_000_000_000; +const store = new Map(); +const secureStore = { + setItemAsync: vi.fn(async (key: string, value: string) => { + store.set(key, value); + await Promise.resolve(); + }), + getItemAsync: vi.fn<(key: string) => Promise>(), +}; + +function snapshotFor( + sessions: { status: string }[] = [ + { status: 'question' }, + { status: 'retry' }, + { status: 'busy' }, + { status: 'busy' }, + ], + status: GlanceableAgentsSnapshot['status'] = 'happy' +): GlanceableAgentsSnapshot { + return buildGlanceableSnapshot({ + sessions, + status, + userId: 'u1', + organizationId: null, + now: NOW, + }); +} + +async function registerAfterRestart(snapshot: GlanceableAgentsSnapshot | null) { + const persist = await import('@/lib/glanceable/persist'); + persist._setSecureStoreForTests(secureStore); + if (snapshot !== null) { + persist.persistGlanceableSink.publish(snapshot); + } + + // Keep only native storage across the simulated JS process restart. + vi.resetModules(); + const freshPersist = await import('@/lib/glanceable/persist'); + freshPersist._setSecureStoreForTests(secureStore); + await import('./register'); + const handler = mocks.registerWidgetTaskHandler.mock.lastCall?.[0]; + if (handler === undefined) { + throw new Error('The widget task handler was not registered'); + } + return handler; +} + +async function runWidgetTask(handler: WidgetTaskHandler, width: number) { + const renders: WidgetRepresentation[] = []; + await handler({ + widgetAction: 'WIDGET_UPDATE', + widgetInfo: { + widgetName: 'ActiveAgentsWidget', + widgetId: 1, + width, + height: 100, + screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, + }, + renderWidget: widget => { + renders.push(widget); + }, + }); + const [rendered] = renders; + if (rendered === undefined || !('light' in rendered)) { + throw new Error('The widget task did not render its themed layouts'); + } + return rendered; +} + +function collectText(node: ReactNode): string[] { + if (Array.isArray(node)) { + return node.flatMap((child: ReactNode) => collectText(child)); + } + if (!isValidElement<{ text?: string; children?: ReactNode }>(node)) { + return []; + } + const text = node.props.text === undefined ? [] : [node.props.text]; + return [...text, ...collectText(node.props.children)]; +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + store.clear(); + secureStore.getItemAsync.mockReset().mockImplementation(async key => { + await Promise.resolve(); + return store.get(key) ?? null; + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +describe.each([120, 250])('registered widget handler at %d dp', width => { + it('restores unexpired persisted counts after a fresh process starts', async () => { + const handler = await registerAfterRestart(snapshotFor()); + const rendered = await runWidgetTask(handler, width); + const expected = + width === 120 + ? ['1 Needs input'] + : ['1 Needs input', '1 Reconnecting', '2 Running', 'Open agents']; + + expect(collectText(rendered.light)).toEqual(expected); + expect(collectText(rendered.dark)).toEqual(expected); + expect(rendered.light.props).toMatchObject({ + clickAction: 'OPEN_URI', + clickActionData: { uri: 'kiloapp:///cloud/sessions' }, + }); + }); + + it.each([ + ['happy', 0], + ['happy', 1], + ['stale', 0], + ['stale', 1], + ] as const)('hides %s counts %d ms after expiry', async (status, elapsed) => { + const stored = snapshotFor(undefined, status); + const handler = await registerAfterRestart(stored); + vi.setSystemTime(Date.parse(stored.expiresAt) + elapsed); + + const rendered = await runWidgetTask(handler, width); + + expect(collectText(rendered.light)).toEqual(['Status expired']); + expect(collectText(rendered.dark)).toEqual(['Status expired']); + }); + + it('renders the existing placeholder when no snapshot is persisted', async () => { + const handler = await registerAfterRestart(null); + + const rendered = await runWidgetTask(handler, width); + + expect(collectText(rendered.light)).toEqual(['No work in progress']); + expect(collectText(rendered.dark)).toEqual(['No work in progress']); + }); + + it('renders the existing placeholder when native storage cannot be read', async () => { + const handler = await registerAfterRestart(snapshotFor()); + secureStore.getItemAsync.mockRejectedValueOnce(new Error('SecureStore unavailable')); + + const rendered = await runWidgetTask(handler, width); + + expect(collectText(rendered.light)).toEqual(['No work in progress']); + expect(collectText(rendered.dark)).toEqual(['No work in progress']); + }); + + it.each([ + ['privacy', 'Agents hidden'], + ['signed_out', 'Sign in to see agents'], + ] as const)('preserves the %s blank even after expiry', async (status, copy) => { + const stored = snapshotFor([], status); + const handler = await registerAfterRestart(stored); + vi.setSystemTime(Date.parse(stored.expiresAt) + 1); + + const rendered = await runWidgetTask(handler, width); + + expect(collectText(rendered.light)).toEqual([copy]); + expect(collectText(rendered.dark)).toEqual([copy]); + }); + + it('prefers newer live widget props to the persisted snapshot', async () => { + const stored = snapshotFor(); + const handler = await registerAfterRestart(stored); + const { androidSink } = await import('./android-sink'); + androidSink.publish({ ...snapshotFor([{ status: 'busy' }]), revision: stored.revision + 1 }); + + const rendered = await runWidgetTask(handler, width); + const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents']; + + expect(collectText(rendered.light)).toEqual(expected); + expect(collectText(rendered.dark)).toEqual(expected); + }); + + it('keeps live widget props published while restoration is pending', async () => { + const stored = snapshotFor(); + const handler = await registerAfterRestart(stored); + const { androidSink } = await import('./android-sink'); + const read = Promise.withResolvers(); + secureStore.getItemAsync.mockReturnValueOnce(read.promise); + + const rendering = runWidgetTask(handler, width); + androidSink.publish({ ...snapshotFor([{ status: 'busy' }]), revision: stored.revision + 1 }); + read.resolve(JSON.stringify(stored)); + const rendered = await rendering; + const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents']; + + expect(collectText(rendered.light)).toEqual(expected); + expect(collectText(rendered.dark)).toEqual(expected); + }); +}); diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts index dfd2ba242b..342343d60d 100644 --- a/apps/mobile/src/glanceable-android/register.ts +++ b/apps/mobile/src/glanceable-android/register.ts @@ -5,11 +5,16 @@ import { } from 'react-native-android-widget'; import { i18n } from '@/i18n'; +import { getLastGlanceableSnapshot, restorePersistedGlanceable } from '@/lib/glanceable/persist'; import { registerGlanceableSink } from '@/lib/glanceable/sink-registry'; import { renderActiveAgentsWidget } from './active-agents-widget'; import { androidSink, getCurrentWidgetProps, handleAppStateActive } from './android-sink'; -import { buildGenericWidgetProps } from './widget-props'; +import { + buildAndroidWidgetProps, + buildExpiredWidgetProps, + buildGenericWidgetProps, +} from './widget-props'; // Register the Android sink at import time. The main-app import of the local // live-update module loads this file, so the sink subscribes before any widget @@ -28,10 +33,27 @@ function translate(key: string): string { return i18n.t(key); } -// eslint-disable-next-line require-await, @typescript-eslint/require-await -- react-native-android-widget requires an async handler; the render path is synchronous registerWidgetTaskHandler(async (task: WidgetTaskHandlerProps) => { const { widgetInfo, renderWidget } = task; - const props = getCurrentWidgetProps() ?? buildGenericWidgetProps(translate); + let props = getCurrentWidgetProps(); + if (props === null) { + // Headless restarts have no live widget props; restore the existing mirror. + await restorePersistedGlanceable(); + const snapshot = getLastGlanceableSnapshot(); + if (snapshot === null) { + props = buildGenericWidgetProps(translate); + } else if ( + snapshot.status !== 'privacy' && + snapshot.status !== 'signed_out' && + Date.parse(snapshot.expiresAt) <= Date.now() + ) { + props = buildExpiredWidgetProps(snapshot, translate); + } else { + props = buildAndroidWidgetProps(snapshot, {}, translate); + } + // A live publish during restoration owns the widget. + props = getCurrentWidgetProps() ?? props; + } renderWidget(renderActiveAgentsWidget(props, widgetInfo)); }); From f2a2901a5f919df08ff6d43fb1b109bbda13a60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 13:30:29 +0200 Subject: [PATCH 4/6] fix(i18n): add the channel name with its Android consumer --- apps/mobile/src/i18n/locales/af.json | 3 ++- apps/mobile/src/i18n/locales/am.json | 3 ++- apps/mobile/src/i18n/locales/ar.json | 3 ++- apps/mobile/src/i18n/locales/az.json | 3 ++- apps/mobile/src/i18n/locales/be.json | 3 ++- apps/mobile/src/i18n/locales/bg.json | 3 ++- apps/mobile/src/i18n/locales/bn.json | 3 ++- apps/mobile/src/i18n/locales/bs.json | 3 ++- apps/mobile/src/i18n/locales/ca.json | 3 ++- apps/mobile/src/i18n/locales/ckb.json | 3 ++- apps/mobile/src/i18n/locales/cs.json | 3 ++- apps/mobile/src/i18n/locales/cy.json | 3 ++- apps/mobile/src/i18n/locales/da.json | 3 ++- apps/mobile/src/i18n/locales/de.json | 3 ++- apps/mobile/src/i18n/locales/el.json | 3 ++- apps/mobile/src/i18n/locales/en.json | 3 ++- apps/mobile/src/i18n/locales/es.json | 3 ++- apps/mobile/src/i18n/locales/et.json | 3 ++- apps/mobile/src/i18n/locales/eu.json | 3 ++- apps/mobile/src/i18n/locales/fa.json | 3 ++- apps/mobile/src/i18n/locales/fi.json | 3 ++- apps/mobile/src/i18n/locales/fil.json | 3 ++- apps/mobile/src/i18n/locales/fr.json | 3 ++- apps/mobile/src/i18n/locales/ga.json | 3 ++- apps/mobile/src/i18n/locales/gl.json | 3 ++- apps/mobile/src/i18n/locales/gu.json | 3 ++- apps/mobile/src/i18n/locales/ha.json | 3 ++- apps/mobile/src/i18n/locales/he.json | 3 ++- apps/mobile/src/i18n/locales/hi.json | 3 ++- apps/mobile/src/i18n/locales/hr.json | 3 ++- apps/mobile/src/i18n/locales/ht.json | 3 ++- apps/mobile/src/i18n/locales/hu.json | 3 ++- apps/mobile/src/i18n/locales/hy.json | 3 ++- apps/mobile/src/i18n/locales/id.json | 3 ++- apps/mobile/src/i18n/locales/ig.json | 3 ++- apps/mobile/src/i18n/locales/is.json | 3 ++- apps/mobile/src/i18n/locales/it.json | 3 ++- apps/mobile/src/i18n/locales/ja.json | 3 ++- apps/mobile/src/i18n/locales/ka.json | 3 ++- apps/mobile/src/i18n/locales/kk.json | 3 ++- apps/mobile/src/i18n/locales/km.json | 3 ++- apps/mobile/src/i18n/locales/kn.json | 3 ++- apps/mobile/src/i18n/locales/ko.json | 3 ++- apps/mobile/src/i18n/locales/lo.json | 3 ++- apps/mobile/src/i18n/locales/lt.json | 3 ++- apps/mobile/src/i18n/locales/lv.json | 3 ++- apps/mobile/src/i18n/locales/mg.json | 3 ++- apps/mobile/src/i18n/locales/mi.json | 3 ++- apps/mobile/src/i18n/locales/mk.json | 3 ++- apps/mobile/src/i18n/locales/ml.json | 3 ++- apps/mobile/src/i18n/locales/mn.json | 3 ++- apps/mobile/src/i18n/locales/mr.json | 3 ++- apps/mobile/src/i18n/locales/ms.json | 3 ++- apps/mobile/src/i18n/locales/mt.json | 3 ++- apps/mobile/src/i18n/locales/my.json | 3 ++- apps/mobile/src/i18n/locales/nb.json | 3 ++- apps/mobile/src/i18n/locales/ne.json | 3 ++- apps/mobile/src/i18n/locales/nl.json | 3 ++- apps/mobile/src/i18n/locales/om.json | 3 ++- apps/mobile/src/i18n/locales/or.json | 3 ++- apps/mobile/src/i18n/locales/pa.json | 3 ++- apps/mobile/src/i18n/locales/pl.json | 3 ++- apps/mobile/src/i18n/locales/ps.json | 3 ++- apps/mobile/src/i18n/locales/pt-BR.json | 3 ++- apps/mobile/src/i18n/locales/pt.json | 3 ++- apps/mobile/src/i18n/locales/ro.json | 3 ++- apps/mobile/src/i18n/locales/ru.json | 3 ++- apps/mobile/src/i18n/locales/si.json | 3 ++- apps/mobile/src/i18n/locales/sk.json | 3 ++- apps/mobile/src/i18n/locales/sl.json | 3 ++- apps/mobile/src/i18n/locales/so.json | 3 ++- apps/mobile/src/i18n/locales/sq.json | 3 ++- apps/mobile/src/i18n/locales/sr.json | 3 ++- apps/mobile/src/i18n/locales/sv.json | 3 ++- apps/mobile/src/i18n/locales/sw.json | 3 ++- apps/mobile/src/i18n/locales/ta.json | 3 ++- apps/mobile/src/i18n/locales/te.json | 3 ++- apps/mobile/src/i18n/locales/th.json | 3 ++- apps/mobile/src/i18n/locales/tr.json | 3 ++- apps/mobile/src/i18n/locales/uk.json | 3 ++- apps/mobile/src/i18n/locales/ur.json | 3 ++- apps/mobile/src/i18n/locales/uz.json | 3 ++- apps/mobile/src/i18n/locales/vi.json | 3 ++- apps/mobile/src/i18n/locales/yo.json | 3 ++- apps/mobile/src/i18n/locales/zh-Hans.json | 3 ++- apps/mobile/src/i18n/locales/zh-Hant.json | 3 ++- apps/mobile/src/i18n/locales/zu.json | 3 ++- 87 files changed, 174 insertions(+), 87 deletions(-) diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index d6d12d53ba..1b4ee043ca 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -3202,6 +3202,7 @@ "openAgents": "Maak agente oop", "running": "LOOP", "needsInput": "benodig invoer", - "reconnecting": "Verbind tans weer" + "reconnecting": "Verbind tans weer", + "channelName": "Aktiewe agente" } } diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 279c02b9f8..5b7ada46af 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -3202,6 +3202,7 @@ "openAgents": "ወኪሎችን ይክፈቱ", "running": "በስራ ላይ", "needsInput": "ግብዓት ይፈልጋል", - "reconnecting": "እንደገና በመገናኘት ላይ" + "reconnecting": "እንደገና በመገናኘት ላይ", + "channelName": "ንቁ ወኪሎች" } } diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index c78bb4b9fc..e8af7140d7 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3286,6 +3286,7 @@ "openAgents": "فتح الوكلاء", "running": "قيد التشغيل", "needsInput": "يتطلب إدخالًا", - "reconnecting": "جارٍ إعادة الاتصال" + "reconnecting": "جارٍ إعادة الاتصال", + "channelName": "الوكلاء النشطون" } } diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 83b88830e0..67e9b552c7 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -3202,6 +3202,7 @@ "openAgents": "Agentləri açın", "running": "İŞLƏYİR", "needsInput": "GİRİŞ TƏLƏB OLUNUR", - "reconnecting": "Yenidən qoşulur" + "reconnecting": "Yenidən qoşulur", + "channelName": "Aktiv agentlər" } } diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index bf323988fc..c418ee0c38 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -3244,6 +3244,7 @@ "openAgents": "Адкрыць агентаў", "running": "ПРАЦУЕ", "needsInput": "патрабуецца ўвод", - "reconnecting": "Паўторнае падключэнне" + "reconnecting": "Паўторнае падключэнне", + "channelName": "Актыўныя агенты" } } diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 99024530e1..5cd8407ff4 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -3202,6 +3202,7 @@ "openAgents": "Отворете агентите", "running": "Изпълнява се", "needsInput": "изисква въвеждане", - "reconnecting": "Повторно свързване" + "reconnecting": "Повторно свързване", + "channelName": "Активни агенти" } } diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index 1a683c1555..ba4620cb81 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -3202,6 +3202,7 @@ "openAgents": "এজেন্টগুলি খুলুন", "running": "চলছে", "needsInput": "ইনপুট প্রয়োজন", - "reconnecting": "পুনরায় সংযোগ করা হচ্ছে" + "reconnecting": "পুনরায় সংযোগ করা হচ্ছে", + "channelName": "সক্রিয় এজেন্ট" } } diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index ad2c5bb2ee..0e49ae771e 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -3223,6 +3223,7 @@ "openAgents": "Otvorite agente", "running": "RADI", "needsInput": "treba unos", - "reconnecting": "Ponovno povezivanje" + "reconnecting": "Ponovno povezivanje", + "channelName": "Aktivni agenti" } } diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index 9e95791e39..424f8144dc 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -3223,6 +3223,7 @@ "openAgents": "Obre els agents", "running": "EN EXECUCIÓ", "needsInput": "requereix entrada", - "reconnecting": "Reconnectant" + "reconnecting": "Reconnectant", + "channelName": "Agents actius" } } diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 3128184dd0..2c1311bd58 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -3202,6 +3202,7 @@ "openAgents": "کردنەوەی ئەجێنتەکان", "running": "لە کاردایە", "needsInput": "پێویستی بە داخڵکردن", - "reconnecting": "لە پەیوەستبوونەوەدایە" + "reconnecting": "لە پەیوەستبوونەوەدایە", + "channelName": "ئەجێنتە چالاکەکان" } } diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 5e2eced1b1..6b1877c7ad 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -3244,6 +3244,7 @@ "openAgents": "Otevřít agenty", "running": "BĚŽÍ", "needsInput": "vyžaduje vstup", - "reconnecting": "Obnovování připojení" + "reconnecting": "Obnovování připojení", + "channelName": "Aktivní agenti" } } diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index 5f362a52c6..c89bfe339d 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3286,6 +3286,7 @@ "openAgents": "Agorwch asiantau", "running": "YN RHEDEG", "needsInput": "angen mewnbwn", - "reconnecting": "Yn ailgysylltu" + "reconnecting": "Yn ailgysylltu", + "channelName": "Asiantau gweithredol" } } diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 266c995879..df2630b2f1 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -3202,6 +3202,7 @@ "openAgents": "Åbn agenter", "running": "KØRER", "needsInput": "kræver input", - "reconnecting": "Genopretter forbindelsen" + "reconnecting": "Genopretter forbindelsen", + "channelName": "Aktive agenter" } } diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index 9b5d2d7531..c2091c7fd8 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -3202,6 +3202,7 @@ "openAgents": "Agenten öffnen", "running": "LÄUFT", "needsInput": "Eingabe erforderlich", - "reconnecting": "Verbindung wird wiederhergestellt" + "reconnecting": "Verbindung wird wiederhergestellt", + "channelName": "Aktive Agenten" } } diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 1afed8347c..2fc7506b84 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -3202,6 +3202,7 @@ "openAgents": "Ανοίξτε τους πράκτορες", "running": "Σε εξέλιξη", "needsInput": "χρειάζεται είσοδο", - "reconnecting": "Επανασύνδεση" + "reconnecting": "Επανασύνδεση", + "channelName": "Ενεργοί πράκτορες" } } diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 511ae214ec..74b46f2668 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -3202,6 +3202,7 @@ "openAgents": "Open agents", "running": "Running", "needsInput": "Needs input", - "reconnecting": "Reconnecting" + "reconnecting": "Reconnecting", + "channelName": "Active agents" } } diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index ad24dc8f7a..15e9b24f29 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -3223,6 +3223,7 @@ "openAgents": "Abrir agentes", "running": "EN EJECUCIÓN", "needsInput": "requiere entrada", - "reconnecting": "Reconectando" + "reconnecting": "Reconectando", + "channelName": "Agentes activos" } } diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 5c6f1acb69..efedf0e21f 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -3202,6 +3202,7 @@ "openAgents": "Avage agendid", "running": "TÖÖTAB", "needsInput": "vajab sisendit", - "reconnecting": "Ühenduse taastamine" + "reconnecting": "Ühenduse taastamine", + "channelName": "Aktiivsed agendid" } } diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index 6816b5ebdd..007513a714 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -3202,6 +3202,7 @@ "openAgents": "Ireki agenteak", "running": "Exekutatzen", "needsInput": "sarreraren zain", - "reconnecting": "Berriro konektatzen" + "reconnecting": "Berriro konektatzen", + "channelName": "Agente aktiboak" } } diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index 7f9dfc7d81..c5686f38c6 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -3202,6 +3202,7 @@ "openAgents": "عامل‌ها را باز کنید", "running": "در حال اجرا", "needsInput": "نیاز به ورودی", - "reconnecting": "در حال اتصال مجدد" + "reconnecting": "در حال اتصال مجدد", + "channelName": "عامل‌های فعال" } } diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 9574f10953..63c2436f4e 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -3202,6 +3202,7 @@ "openAgents": "Avaa agentit", "running": "KÄYNNISSÄ", "needsInput": "vaatii syötettä", - "reconnecting": "Yhdistetään uudelleen" + "reconnecting": "Yhdistetään uudelleen", + "channelName": "Aktiiviset agentit" } } diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 0fb827ddc8..c8c7507ac2 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -3202,6 +3202,7 @@ "openAgents": "Buksan ang mga agent", "running": "TUMATAKBO", "needsInput": "kailangan ng input", - "reconnecting": "Muling kumokonekta" + "reconnecting": "Muling kumokonekta", + "channelName": "Mga aktibong agent" } } diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 87b5af0562..ec83f5440e 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -3223,6 +3223,7 @@ "openAgents": "Ouvrir les agents", "running": "EN COURS", "needsInput": "saisie requise", - "reconnecting": "Reconnexion en cours" + "reconnecting": "Reconnexion en cours", + "channelName": "Agents actifs" } } diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index a240f956b2..5e4c91f5d2 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3265,6 +3265,7 @@ "openAgents": "Oscail gníomhairí", "running": "AG RITH", "needsInput": "teastaíonn ionchur", - "reconnecting": "Ag athcheangal" + "reconnecting": "Ag athcheangal", + "channelName": "Gníomhairí gníomhacha" } } diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index 26faac0f14..f3a70de821 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -3202,6 +3202,7 @@ "openAgents": "Abrir axentes", "running": "Executando", "needsInput": "precisa entrada", - "reconnecting": "Reconectando" + "reconnecting": "Reconectando", + "channelName": "Axentes activos" } } diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 189a6d3be7..c271998e8f 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -3202,6 +3202,7 @@ "openAgents": "એજન્ટો ખોલો", "running": "ચાલી રહ્યું છે", "needsInput": "ઇનપુટ જરૂરી", - "reconnecting": "ફરી કનેક્ટ થઈ રહ્યું છે" + "reconnecting": "ફરી કનેક્ટ થઈ રહ્યું છે", + "channelName": "સક્રિય એજન્ટો" } } diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index 9db5546541..183f2b80f6 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -3202,6 +3202,7 @@ "openAgents": "Buɗe wakilai", "running": "Ana gudana", "needsInput": "yana buƙatar bayani", - "reconnecting": "Ana sake haɗawa" + "reconnecting": "Ana sake haɗawa", + "channelName": "Wakilai da ke aiki" } } diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 506cad5462..d18236d0a7 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -3223,6 +3223,7 @@ "openAgents": "פתח סוכנים", "running": "רץ", "needsInput": "נדרש קלט", - "reconnecting": "מתחבר מחדש" + "reconnecting": "מתחבר מחדש", + "channelName": "סוכנים פעילים" } } diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 60947511e1..4ae7b35d52 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -3202,6 +3202,7 @@ "openAgents": "एजेंट खोलें", "running": "चालू", "needsInput": "इनपुट आवश्यक", - "reconnecting": "फिर से कनेक्ट हो रहा है" + "reconnecting": "फिर से कनेक्ट हो रहा है", + "channelName": "सक्रिय एजेंट" } } diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 263fdaa48c..7046d3715b 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -3223,6 +3223,7 @@ "openAgents": "Otvorite agente", "running": "RADI", "needsInput": "treba unos", - "reconnecting": "Ponovno povezivanje" + "reconnecting": "Ponovno povezivanje", + "channelName": "Aktivni agenti" } } diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index 44aee58be6..0f458c784b 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -3202,6 +3202,7 @@ "openAgents": "Louvri ajans yo", "running": "AP KOURI", "needsInput": "bezwen input", - "reconnecting": "Ap rekonekte" + "reconnecting": "Ap rekonekte", + "channelName": "Ajans aktif yo" } } diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index 5813696bc2..3009fd6a05 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -3202,6 +3202,7 @@ "openAgents": "Ügynökök megnyitása", "running": "Folyamatban", "needsInput": "bemenetet igényel", - "reconnecting": "Újracsatlakozás" + "reconnecting": "Újracsatlakozás", + "channelName": "Aktív ügynökök" } } diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index f30a70dd75..7b6d4c9435 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -3202,6 +3202,7 @@ "openAgents": "Բացեք գործակալները", "running": "Ընթացքի մեջ է", "needsInput": "մուտքագրման կարիք ունի", - "reconnecting": "Կրկին միացում" + "reconnecting": "Կրկին միացում", + "channelName": "Ակտիվ գործակալներ" } } diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index 28e02535b6..a3254f067b 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -3202,6 +3202,7 @@ "openAgents": "Buka agen", "running": "BERJALAN", "needsInput": "memerlukan input", - "reconnecting": "Menghubungkan kembali" + "reconnecting": "Menghubungkan kembali", + "channelName": "Agen aktif" } } diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index c17e85e58c..2ff50d86aa 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -3202,6 +3202,7 @@ "openAgents": "Mepee ndị ọrụ", "running": "NA-AGBA", "needsInput": "chọrọ ntinye", - "reconnecting": "Na-ejikọ ọzọ" + "reconnecting": "Na-ejikọ ọzọ", + "channelName": "Ndị ọrụ na-arụ ọrụ" } } diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index 0918a986fe..af9f5a572b 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -3202,6 +3202,7 @@ "openAgents": "Opna umboð", "running": "Í gangi", "needsInput": "þarfnast inntaks", - "reconnecting": "Tengist aftur" + "reconnecting": "Tengist aftur", + "channelName": "Virk umboð" } } diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index 8aad0e857b..8bdd22a3f1 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -3223,6 +3223,7 @@ "openAgents": "Apri agenti", "running": "IN ESECUZIONE", "needsInput": "richiede input", - "reconnecting": "Riconnessione in corso" + "reconnecting": "Riconnessione in corso", + "channelName": "Agenti attivi" } } diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index 83ce883471..54e1029390 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -3202,6 +3202,7 @@ "openAgents": "エージェントを開く", "running": "実行中", "needsInput": "入力が必要", - "reconnecting": "再接続中" + "reconnecting": "再接続中", + "channelName": "アクティブなエージェント" } } diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index d4e298981a..14450163af 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -3202,6 +3202,7 @@ "openAgents": "აგენტების გახსნა", "running": "მუშაობს", "needsInput": "მოითხოვს შეყვანას", - "reconnecting": "კავშირის აღდგენა" + "reconnecting": "კავშირის აღდგენა", + "channelName": "აქტიური აგენტები" } } diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index 63398c3da4..4c944a7290 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -3202,6 +3202,7 @@ "openAgents": "Агенттерді ашу", "running": "Орындалуда", "needsInput": "енгізу қажет", - "reconnecting": "Қайта қосылуда" + "reconnecting": "Қайта қосылуда", + "channelName": "Белсенді агенттер" } } diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index 951b891c95..4ae77a6a52 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -3202,6 +3202,7 @@ "openAgents": "បើកភ្នាក់ងារ", "running": "កំពុងដំណើរការ", "needsInput": "ត្រូវការបញ្ចូល", - "reconnecting": "កំពុងភ្ជាប់ឡើងវិញ" + "reconnecting": "កំពុងភ្ជាប់ឡើងវិញ", + "channelName": "ភ្នាក់ងារសកម្ម" } } diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index aa081b63d1..47ed0eae90 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -3202,6 +3202,7 @@ "openAgents": "ಏಜೆಂಟ್‌ಗಳನ್ನು ತೆರೆಯಿರಿ", "running": "ಚಾಲನೆಯಲ್ಲಿದೆ", "needsInput": "ಇನ್‌ಪುಟ್ ಅಗತ್ಯವಿದೆ", - "reconnecting": "ಮರುಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ" + "reconnecting": "ಮರುಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ", + "channelName": "ಸಕ್ರಿಯ ಏಜೆಂಟ್‌ಗಳು" } } diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index de38c321ae..5439a2bab3 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -3202,6 +3202,7 @@ "openAgents": "에이전트 열기", "running": "실행 중", "needsInput": "입력 필요", - "reconnecting": "다시 연결 중" + "reconnecting": "다시 연결 중", + "channelName": "활성 에이전트" } } diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 8bc6075cd2..8d6b277352 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -3202,6 +3202,7 @@ "openAgents": "ເປີດຕົວແທນ", "running": "ກຳລັງດຳເນີນການ", "needsInput": "ຕ້ອງການຂໍ້ມູນເຂົ້າ", - "reconnecting": "ກຳລັງເຊື່ອມຕໍ່ຄືນ" + "reconnecting": "ກຳລັງເຊື່ອມຕໍ່ຄືນ", + "channelName": "ຕົວແທນທີ່ກຳລັງເຮັດວຽກ" } } diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index 16f66764b0..3bc314f349 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -3244,6 +3244,7 @@ "openAgents": "Atidaryti agentus", "running": "Vykdoma", "needsInput": "reikia įvesties", - "reconnecting": "Jungiamasi iš naujo" + "reconnecting": "Jungiamasi iš naujo", + "channelName": "Aktyvūs agentai" } } diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 51f3ea955f..05a7724241 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -3223,6 +3223,7 @@ "openAgents": "Atvērt aģentus", "running": "DARBOJAS", "needsInput": "nepieciešama ievade", - "reconnecting": "Atkārtoti izveido savienojumu" + "reconnecting": "Atkārtoti izveido savienojumu", + "channelName": "Aktīvie aģenti" } } diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index b88b5054e0..1a3dc4d8ee 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -3202,6 +3202,7 @@ "openAgents": "Sokafy ny agent", "running": "MANDEHA", "needsInput": "mila fampidirana", - "reconnecting": "Mampifandray indray" + "reconnecting": "Mampifandray indray", + "channelName": "Agent mavitrika" } } diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 996cc80bd4..1dc66c66c0 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -3202,6 +3202,7 @@ "openAgents": "Whakatuwheratia ngā māngai", "running": "Kei te oma", "needsInput": "e hiahia ana ki te whakaurunga", - "reconnecting": "Kei te hono anō" + "reconnecting": "Kei te hono anō", + "channelName": "Ngā māngai hohe" } } diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index b99d7bde6b..e4ada1704f 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -3202,6 +3202,7 @@ "openAgents": "Отворете ги агентите", "running": "Во тек", "needsInput": "бара внес", - "reconnecting": "Повторно поврзување" + "reconnecting": "Повторно поврзување", + "channelName": "Активни агенти" } } diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 70513a7aab..d6692e6a94 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -3202,6 +3202,7 @@ "openAgents": "ഏജന്റുകളെ തുറക്കുക", "running": "പ്രവർത്തിക്കുന്നു", "needsInput": "ഇൻപുട്ട് ആവശ്യമാണ്", - "reconnecting": "വീണ്ടും ബന്ധിപ്പിക്കുന്നു" + "reconnecting": "വീണ്ടും ബന്ധിപ്പിക്കുന്നു", + "channelName": "സജീവ ഏജന്റുകൾ" } } diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index 96bf6fb332..1d34ea0a0e 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -3202,6 +3202,7 @@ "openAgents": "Агентуудыг нээх", "running": "АЖИЛЛАЖ БАЙНА", "needsInput": "оролт шаардлагатай", - "reconnecting": "Дахин холбогдож байна" + "reconnecting": "Дахин холбогдож байна", + "channelName": "Идэвхтэй агентууд" } } diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index b2cfb3efd9..c719d9f452 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -3202,6 +3202,7 @@ "openAgents": "एजंट्स उघडा", "running": "चालू आहे", "needsInput": "इनपुट आवश्यक", - "reconnecting": "पुन्हा जोडत आहे" + "reconnecting": "पुन्हा जोडत आहे", + "channelName": "सक्रिय एजंट्स" } } diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index e43ee7f27c..37b9e24910 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -3202,6 +3202,7 @@ "openAgents": "Buka ejen", "running": "Sedang berjalan", "needsInput": "perlu input", - "reconnecting": "Menyambung semula" + "reconnecting": "Menyambung semula", + "channelName": "Ejen aktif" } } diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index 35c4df9481..cf0f0b7279 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3265,6 +3265,7 @@ "openAgents": "Iftaħ l-aġenti", "running": "Għaddej", "needsInput": "jeħtieġ input", - "reconnecting": "Qed jerġa' jaqbad" + "reconnecting": "Qed jerġa' jaqbad", + "channelName": "Aġenti attivi" } } diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index edcc519920..26475618a2 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -3202,6 +3202,7 @@ "openAgents": "agent များကို ဖွင့်ပါ", "running": "လည်ပတ်နေသည်", "needsInput": "ထည့်သွင်းမှု လိုအပ်သည်", - "reconnecting": "ပြန်ချိတ်ဆက်နေသည်" + "reconnecting": "ပြန်ချိတ်ဆက်နေသည်", + "channelName": "လုပ်ဆောင်နေသော agent များ" } } diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index 43761b6c59..ec6a72b9ba 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -3202,6 +3202,7 @@ "openAgents": "Åpne agenter", "running": "KJØRER", "needsInput": "trenger innspill", - "reconnecting": "Kobler til på nytt" + "reconnecting": "Kobler til på nytt", + "channelName": "Aktive agenter" } } diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index de1427f1cc..838478720b 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -3202,6 +3202,7 @@ "openAgents": "एजेन्टहरू खोल्नुहोस्", "running": "चलिरहेको", "needsInput": "इनपुट चाहिन्छ", - "reconnecting": "पुनः जडान गर्दै" + "reconnecting": "पुनः जडान गर्दै", + "channelName": "सक्रिय एजेन्टहरू" } } diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index 41325b46f0..8c4666a517 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -3202,6 +3202,7 @@ "openAgents": "Agents openen", "running": "Bezig", "needsInput": "heeft invoer nodig", - "reconnecting": "Opnieuw verbinden" + "reconnecting": "Opnieuw verbinden", + "channelName": "Actieve agents" } } diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index 1275746730..d5752001be 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -3202,6 +3202,7 @@ "openAgents": "Eejentoota banaa", "running": "Hojii irra jira", "needsInput": "seensa barbaada", - "reconnecting": "Irra deebi'ee walqabachaa jira" + "reconnecting": "Irra deebi'ee walqabachaa jira", + "channelName": "Eejentoota hojii irra jiran" } } diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index 5f0ddff01a..7484c06307 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -3202,6 +3202,7 @@ "openAgents": "ଏଜେଣ୍ଟଗୁଡ଼ିକ ଖୋଲନ୍ତୁ", "running": "ଚାଲୁଛି", "needsInput": "ଇନପୁଟ୍ ଆବଶ୍ୟକ", - "reconnecting": "ପୁଣି ସଂଯୋଗ ହେଉଛି" + "reconnecting": "ପୁଣି ସଂଯୋଗ ହେଉଛି", + "channelName": "ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ" } } diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index 693033ab74..1f02febb3f 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -3202,6 +3202,7 @@ "openAgents": "ਏਜੰਟ ਖੋਲ੍ਹੋ", "running": "ਚੱਲ ਰਿਹਾ ਹੈ", "needsInput": "ਇਨਪੁੱਟ ਦੀ ਲੋੜ ਹੈ", - "reconnecting": "ਮੁੜ ਕਨੈਕਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ" + "reconnecting": "ਮੁੜ ਕਨੈਕਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ", + "channelName": "ਸਰਗਰਮ ਏਜੰਟ" } } diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index cad09ac2c4..556da10f5e 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -3244,6 +3244,7 @@ "openAgents": "Otwórz agentów", "running": "W toku", "needsInput": "wymaga danych", - "reconnecting": "Ponowne łączenie" + "reconnecting": "Ponowne łączenie", + "channelName": "Aktywni agenci" } } diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index 93ecebe60c..7a0e195688 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -3202,6 +3202,7 @@ "openAgents": "اجنټان پرانیزئ", "running": "روان", "needsInput": "ورودی ته اړتیا لري", - "reconnecting": "بیا نښلېږي" + "reconnecting": "بیا نښلېږي", + "channelName": "فعال اجنټان" } } diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index a90088c38b..c4aa487c44 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -3223,6 +3223,7 @@ "openAgents": "Abrir agentes", "running": "Em execução", "needsInput": "requer entrada", - "reconnecting": "Reconectando" + "reconnecting": "Reconectando", + "channelName": "Agentes ativos" } } diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index b046797fb2..215759a7b1 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -3223,6 +3223,7 @@ "openAgents": "Abrir agentes", "running": "EM EXECUÇÃO", "needsInput": "requer entrada", - "reconnecting": "A restabelecer ligação" + "reconnecting": "A restabelecer ligação", + "channelName": "Agentes ativos" } } diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index edc32efe7a..8725fe53ee 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -3223,6 +3223,7 @@ "openAgents": "Deschide agenții", "running": "Rulează", "needsInput": "necesită introducere", - "reconnecting": "Se reconectează" + "reconnecting": "Se reconectează", + "channelName": "Agenți activi" } } diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index 9d0433771e..c8e591b265 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -3244,6 +3244,7 @@ "openAgents": "Открыть агентов", "running": "Выполняется", "needsInput": "требует ввода", - "reconnecting": "Повторное подключение" + "reconnecting": "Повторное подключение", + "channelName": "Активные агенты" } } diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index b91444bd82..c6f64d4d94 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -3202,6 +3202,7 @@ "openAgents": "නියෝජිතයන් විවෘත කරන්න", "running": "ධාවනය වෙමින්", "needsInput": "ආදානය අවශ්යයි", - "reconnecting": "නැවත සම්බන්ධ වෙමින්" + "reconnecting": "නැවත සම්බන්ධ වෙමින්", + "channelName": "සක්‍රිය නියෝජිතයන්" } } diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 5a2da6540d..24a8d360d9 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -3244,6 +3244,7 @@ "openAgents": "Otvoriť agentov", "running": "Prebieha", "needsInput": "vyžaduje vstup", - "reconnecting": "Opätovné pripájanie" + "reconnecting": "Opätovné pripájanie", + "channelName": "Aktívni agenti" } } diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index 6138aa0a4d..0aad448db8 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -3244,6 +3244,7 @@ "openAgents": "Odprite agente", "running": "DELUJE", "needsInput": "potrebuje vnos", - "reconnecting": "Ponovno povezovanje" + "reconnecting": "Ponovno povezovanje", + "channelName": "Aktivni agenti" } } diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 7b09b06741..24bf096d20 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -3202,6 +3202,7 @@ "openAgents": "Fur wakiillada", "running": "Socodaya", "needsInput": "u baahan wax-soo-gal", - "reconnecting": "Dib u xiriirinaya" + "reconnecting": "Dib u xiriirinaya", + "channelName": "Wakiillada firfircoon" } } diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index e347f02620..b55329e94a 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -3202,6 +3202,7 @@ "openAgents": "Hapni agjentët", "running": "Në ekzekutim", "needsInput": "ka nevojë për të dhëna", - "reconnecting": "Duke u rilidhur" + "reconnecting": "Duke u rilidhur", + "channelName": "Agjentët aktivë" } } diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 9ff967432a..4132753bc9 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -3223,6 +3223,7 @@ "openAgents": "Otvorite agente", "running": "U toku", "needsInput": "zahteva unos", - "reconnecting": "Ponovno povezivanje" + "reconnecting": "Ponovno povezivanje", + "channelName": "Aktivni agenti" } } diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 23133bef21..7be04ca3d9 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -3202,6 +3202,7 @@ "openAgents": "Öppna agenter", "running": "KÖRS", "needsInput": "kräver indata", - "reconnecting": "Återansluter" + "reconnecting": "Återansluter", + "channelName": "Aktiva agenter" } } diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index 074cfb9315..a34f9ff458 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -3202,6 +3202,7 @@ "openAgents": "Fungua mawakala", "running": "Inaendelea", "needsInput": "inahitaji mchango", - "reconnecting": "Inaunganisha tena" + "reconnecting": "Inaunganisha tena", + "channelName": "Mawakala wanaofanya kazi" } } diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index 1888c8722e..476ce4b157 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -3202,6 +3202,7 @@ "openAgents": "முகவர்களைத் திறக்கவும்", "running": "இயங்குகிறது", "needsInput": "உள்ளீடு தேவை", - "reconnecting": "மீண்டும் இணைக்கிறது" + "reconnecting": "மீண்டும் இணைக்கிறது", + "channelName": "செயலில் உள்ள முகவர்கள்" } } diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index 27fdafb1b4..78267afaa8 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -3202,6 +3202,7 @@ "openAgents": "ఏజెంట్లను తెరవండి", "running": "నడుస్తోంది", "needsInput": "ఇన్పుట్ అవసరం", - "reconnecting": "మళ్లీ కనెక్ట్ అవుతోంది" + "reconnecting": "మళ్లీ కనెక్ట్ అవుతోంది", + "channelName": "చురుకైన ఏజెంట్లు" } } diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index be2437ae64..afc37c8ae7 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -3202,6 +3202,7 @@ "openAgents": "เปิดเอเจนต์", "running": "กำลังทำงาน", "needsInput": "ต้องป้อนข้อมูล", - "reconnecting": "กำลังเชื่อมต่อใหม่" + "reconnecting": "กำลังเชื่อมต่อใหม่", + "channelName": "เอเจนต์ที่กำลังทำงาน" } } diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index 7117b3d714..9b4ad75bbf 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -3202,6 +3202,7 @@ "openAgents": "Ajanları açın", "running": "Çalışıyor", "needsInput": "Girdi gerekli", - "reconnecting": "Yeniden bağlanılıyor" + "reconnecting": "Yeniden bağlanılıyor", + "channelName": "Etkin ajanlar" } } diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 01b6312b42..462e15e333 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -3244,6 +3244,7 @@ "openAgents": "Відкрити агентів", "running": "Виконується", "needsInput": "потребує вводу", - "reconnecting": "Повторне підключення" + "reconnecting": "Повторне підключення", + "channelName": "Активні агенти" } } diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index 4603f57ea1..51a4e2f11d 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -3202,6 +3202,7 @@ "openAgents": "ایجنٹس کھولیں", "running": "چل رہا ہے", "needsInput": "ان پٹ درکار", - "reconnecting": "دوبارہ منسلک ہو رہا ہے" + "reconnecting": "دوبارہ منسلک ہو رہا ہے", + "channelName": "فعال ایجنٹس" } } diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index eaa3609da4..575f1032aa 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -3202,6 +3202,7 @@ "openAgents": "Agentlarni oching", "running": "Ishlamoqda", "needsInput": "kiritish kerak", - "reconnecting": "Qayta ulanmoqda" + "reconnecting": "Qayta ulanmoqda", + "channelName": "Faol agentlar" } } diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 86c8419a80..164bdaaebf 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -3202,6 +3202,7 @@ "openAgents": "Mở tác nhân", "running": "ĐANG CHẠY", "needsInput": "cần nhập", - "reconnecting": "Đang kết nối lại" + "reconnecting": "Đang kết nối lại", + "channelName": "Tác nhân đang hoạt động" } } diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index eb769c9306..669d001c41 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -3202,6 +3202,7 @@ "openAgents": "Ṣii awọn aṣoju", "running": "ǸJẸ́ ṢÍṢIṢẸ́", "needsInput": "nilo igbewọle", - "reconnecting": "Ti n tun sopọ" + "reconnecting": "Ti n tun sopọ", + "channelName": "Awọn aṣoju to n ṣiṣẹ" } } diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 3fb3b32458..b3e610d7d7 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -3202,6 +3202,7 @@ "openAgents": "打开代理", "running": "运行中", "needsInput": "需要输入", - "reconnecting": "正在重新连接" + "reconnecting": "正在重新连接", + "channelName": "活动代理" } } diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 5d43030b65..d66c9e3e3e 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -3202,6 +3202,7 @@ "openAgents": "開啟代理", "running": "執行中", "needsInput": "需要輸入", - "reconnecting": "正在重新連線" + "reconnecting": "正在重新連線", + "channelName": "使用中的代理" } } diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index 89fb9ef2c9..8e866a5cbc 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -3202,6 +3202,7 @@ "openAgents": "Vula ama-agent", "running": "IYASEBENZA", "needsInput": "idinga okokufaka", - "reconnecting": "Ixhuma kabusha" + "reconnecting": "Ixhuma kabusha", + "channelName": "Ama-agent asebenzayo" } } From a26d710a99454f4e629b5a35f3da77528d7acb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 14:28:15 +0200 Subject: [PATCH 5/6] fix(mobile): restore Android glanceable count presentation --- .../ActiveAgentsLiveUpdateModule.kt | 13 +- .../active-agents-widget.test.ts | 40 +++++ .../glanceable-android/android-sink.test.ts | 162 ++++++++++++++---- .../src/glanceable-android/android-sink.ts | 17 +- .../src/glanceable-android/live-update.ts | 12 +- .../glanceable-android/widget-props.test.ts | 150 ++++++++++++---- .../src/glanceable-android/widget-props.ts | 34 ++-- .../src/lib/glanceable/presentation.test.ts | 61 +++++++ .../mobile/src/lib/glanceable/presentation.ts | 20 +++ 9 files changed, 417 insertions(+), 92 deletions(-) diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 70a5bba9a5..7b1a4cc422 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -26,12 +26,12 @@ class ActiveAgentsLiveUpdateModule : Module() { isPromotionCapable() } - Function("start") { title: String, text: String, promotion: Boolean -> - post(title, text, promotion) + Function("start") { title: String, text: String, compactText: String?, promotion: Boolean -> + post(title, text, compactText, promotion) } - Function("update") { title: String, text: String, promotion: Boolean -> - post(title, text, promotion) + Function("update") { title: String, text: String, compactText: String?, promotion: Boolean -> + post(title, text, compactText, promotion) } Function("end") { @@ -91,7 +91,7 @@ class ActiveAgentsLiveUpdateModule : Module() { ) } - private fun post(title: String, text: String, promotion: Boolean) { + private fun post(title: String, text: String, compactText: String?, promotion: Boolean) { val builder = newBuilder(title) .setSmallIcon(smallIconId()) .setContentTitle(title) @@ -106,6 +106,7 @@ class ActiveAgentsLiveUpdateModule : Module() { // setRequestPromotedOngoing does not exist; use the documented flag setter. if (promotion && isPromotionCapable()) { builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true) + builder.setShortCriticalText(compactText) builder.setStyle(Notification.ProgressStyle()) } @@ -122,4 +123,4 @@ class ActiveAgentsLiveUpdateModule : Module() { const val OPEN_AGENTS_DEEP_LINK = "kiloapp:///cloud/sessions" const val OPEN_AGENTS_REQUEST_CODE = 1002 } -} \ No newline at end of file +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index 555cc6a791..1ce6c7a4f3 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -23,6 +23,7 @@ type MockElement = { text?: string; clickAction?: string; clickActionData?: { uri?: string }; + accessibilityLabel?: string; style?: { backgroundColor?: string }; children?: unknown; }; @@ -34,6 +35,7 @@ const COPY: Record = { 'glanceable.running': 'Running', 'glanceable.empty': 'No work in progress', 'glanceable.expired': 'Status expired', + 'glanceable.stale': 'Updates delayed', 'glanceable.openAgents': 'Open agents', }; @@ -123,6 +125,44 @@ describe('renderActiveAgentsWidget', () => { expect(text).toEqual(['1 Needs input', '1 Running', 'Open agents']); }); + it.each([ + { width: 120, visibleText: ['2 Needs input'] }, + { + width: 250, + visibleText: [ + '2 Needs input', + '3 Reconnecting', + '4 Running', + 'Updates delayed', + 'Open agents', + ], + }, + ])( + 'speaks stale numeric counts and keeps the deep link at width $width', + ({ width, visibleText }) => { + const props = buildAndroidWidgetProps( + { + ...snapshotFor([], 0, 'stale'), + needsInput: 2, + reconnecting: 3, + running: 4, + }, + {}, + translate + ); + const rep = render(props, width); + + for (const surface of [rep.light, rep.dark]) { + expect(surface.props.accessibilityLabel).toBe( + 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents' + ); + expect(collectText(surface)).toEqual(visibleText); + expect(surface.props.clickAction).toBe('OPEN_URI'); + expect(surface.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' }); + } + } + ); + it('hides counts and shows expired copy for an expired snapshot', () => { const props = buildAndroidWidgetProps( { diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index 1033e2b90a..911b441de1 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -5,6 +5,8 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { i18n } from '@/i18n'; + import { _resetAndroidSinkForTests, androidSink, @@ -14,16 +16,33 @@ import { import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission'; import { _resetAndroidPermissionAlertForTests } from './permission-alert'; -const mocks = vi.hoisted(() => ({ - native: { - isPromotionCapable: vi.fn(() => true), - start: vi.fn(), - update: vi.fn(), - end: vi.fn(), - }, - requestWidgetUpdate: vi.fn(), - alert: vi.fn(), -})); +const mocks = vi.hoisted(() => { + let notification: { + title: string; + text: string; + compactText: string | null; + promotion: boolean; + } | null = null; + + // eslint-disable-next-line max-params -- the fake mirrors the four positional native bridge arguments + function post(title: string, text: string, compactText: string | null, promotion: boolean): void { + notification = { title, text, compactText, promotion }; + } + + return { + native: { + isPromotionCapable: vi.fn(() => true), + start: vi.fn(post), + update: vi.fn(post), + end: vi.fn(() => { + notification = null; + }), + }, + getNotification: () => notification, + requestWidgetUpdate: vi.fn(), + alert: vi.fn(), + }; +}); vi.mock('expo', () => ({ requireOptionalNativeModule: () => mocks.native, @@ -62,6 +81,13 @@ function snapshotFor( }); } +const MIXED = { + ...snapshotFor([], 0, 'happy'), + needsInput: 2, + reconnecting: 3, + running: 4, +}; + async function flushAsync(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -91,6 +117,7 @@ beforeEach(() => { // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); mocks.native.isPromotionCapable.mockReturnValue(true); + mocks.native.end(); mocks.native.start.mockClear(); mocks.native.update.mockClear(); mocks.native.end.mockClear(); @@ -104,25 +131,67 @@ afterEach(() => { }); describe('androidSink start and update', () => { - it('starts once and updates the same notification id on a newer revision with promotion', async () => { - androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + it('forwards the ranked compact number and all counts on start and update', async () => { + androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 1), CTX); + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '2 Needs input, 3 Reconnecting, 4 Running', + compactText: '2', + promotion: true, + }); + + androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX); await flushAsync(); - + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '3 Reconnecting, 4 Running', + compactText: '3', + promotion: true, + }); + + androidSink.startOrUpdate({ ...MIXED, revision: 3, needsInput: 0, reconnecting: 0 }, CTX); + await flushAsync(); + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '4 Running', + compactText: '4', + promotion: true, + }); expect(mocks.native.start).toHaveBeenCalledTimes(1); - expect(mocks.native.update).toHaveBeenCalledTimes(1); - expect(mocks.native.start).toHaveBeenCalledWith('Active agents', '1 Running', true); - expect(mocks.native.update).toHaveBeenCalledWith('Active agents', '1 Running', true); + expect(mocks.native.update).toHaveBeenCalledTimes(2); }); - it('passes promotion false when the device is not capable', async () => { + it('keeps the full summary when the device cannot promote', async () => { mocks.native.isPromotionCapable.mockReturnValue(false); - androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '2 Needs input, 3 Reconnecting, 4 Running', + compactText: '2', + promotion: false, + }); + }); + + it('forwards compact text when concurrent permission checks start then update', async () => { + const deferred = deferredPermission(); + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules + _setPermissionReaderForTests(() => deferred.promise); + androidSink.startOrUpdate(MIXED, CTX); + androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX); + deferred.resolve('granted'); await flushAsync(); + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '3 Reconnecting, 4 Running', + compactText: '3', + promotion: true, + }); expect(mocks.native.start).toHaveBeenCalledTimes(1); - expect(mocks.native.start).toHaveBeenCalledWith(expect.any(String), expect.any(String), false); + expect(mocks.native.update).toHaveBeenCalledTimes(1); }); it('does not start ongoing when notification permission is denied', async () => { @@ -177,13 +246,17 @@ describe('androidSink start and update', () => { expect(mocks.native.update).toHaveBeenCalledTimes(1); }); - it('never starts for a waiting snapshot', async () => { - androidSink.startOrUpdate(snapshotFor([], 0, 'waiting'), CTX); - await flushAsync(); - - expect(mocks.native.start).not.toHaveBeenCalled(); - expect(mocks.native.update).not.toHaveBeenCalled(); - }); + it.each(['waiting', 'empty', 'expired', 'signed_out', 'privacy'] as const)( + 'never starts for a %s snapshot without eligible work', + async status => { + androidSink.startOrUpdate(snapshotFor([], 0, status), CTX); + await flushAsync(); + + expect(mocks.getNotification()).toBeNull(); + expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.native.update).not.toHaveBeenCalled(); + } + ); }); describe('androidSink widget publish and end', () => { @@ -198,6 +271,20 @@ describe('androidSink widget publish and end', () => { expect(getCurrentWidgetProps()?.primaryCount).toBe(1); }); + it('publishes the stale warning and retained counts through the native bridge', async () => { + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + androidSink.publish({ ...MIXED, revision: 2, status: 'stale' }); + + const notification = mocks.getNotification(); + expect(notification?.text).toContain(i18n.t('glanceable.stale')); + expect(notification?.text).toContain('2 Needs input, 3 Reconnecting, 4 Running'); + expect(notification?.compactText).toBe('2'); + expect(getCurrentWidgetProps()?.accessibilityLabel).toContain( + '2 Needs input, 3 Reconnecting, 4 Running, Open agents' + ); + }); + it('blanks with privacy copy and dismisses the notification on end', async () => { androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); await flushAsync(); @@ -206,9 +293,15 @@ describe('androidSink widget publish and end', () => { androidSink.publish(snapshotFor([], 1, 'privacy')); expect(getCurrentWidgetProps()?.statusLine).toBe('Agents hidden'); expect(getCurrentWidgetProps()?.countLines).toEqual([]); - expect(mocks.native.update).toHaveBeenCalledWith('Active agents', 'Agents hidden', true); + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: 'Agents hidden', + compactText: null, + promotion: true, + }); androidSink.endImmediate(); + expect(mocks.getNotification()).toBeNull(); expect(mocks.native.end).toHaveBeenCalledTimes(1); }); @@ -273,17 +366,22 @@ describe('handleAppStateActive permission alert', () => { expect(mocks.alert).toHaveBeenCalledTimes(1); }); - it('retries the pending start when permission is granted on foreground', async () => { + it('forwards the pending compact number when permission is granted on foreground', async () => { // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('denied')); - androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.native.start).not.toHaveBeenCalled(); + expect(mocks.getNotification()).toBeNull(); // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); await handleAppStateActive(); - expect(mocks.native.start).toHaveBeenCalledTimes(1); + expect(mocks.getNotification()).toEqual({ + title: 'Active agents', + text: '2 Needs input, 3 Reconnecting, 4 Running', + compactText: '2', + promotion: true, + }); expect(mocks.alert).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index a38084b1a4..e147d199ca 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -18,6 +18,7 @@ import { showAndroidPermissionAlertOnce } from './permission-alert'; import { type AndroidWidgetProps, buildAndroidWidgetProps, + buildCompactNotificationText, buildExpiredWidgetProps, buildOngoingNotificationText, } from './widget-props'; @@ -95,9 +96,10 @@ async function tryStartOrUpdate( } const title = translate(NOTIFICATION_TITLE_KEY); const text = buildOngoingNotificationText(snapshot, {}, translate); + const compactText = buildCompactNotificationText(snapshot, {}); if (notificationActive) { - updateLiveUpdate(title, text); + updateLiveUpdate(title, text, compactText); revision = snapshot.revision; return; } @@ -111,12 +113,12 @@ async function tryStartOrUpdate( // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- a concurrent start/retry can set notificationActive while awaiting permission if (notificationActive) { if (snapshot.revision > revision) { - updateLiveUpdate(title, text); + updateLiveUpdate(title, text, compactText); revision = snapshot.revision; } return; } - startLiveUpdate(title, text); + startLiveUpdate(title, text, compactText); notificationActive = true; revision = snapshot.revision; pending = null; @@ -132,7 +134,11 @@ function retryPendingStart(): void { return; } const title = translate(NOTIFICATION_TITLE_KEY); - startLiveUpdate(title, buildOngoingNotificationText(p.snapshot, {}, translate)); + startLiveUpdate( + title, + buildOngoingNotificationText(p.snapshot, {}, translate), + buildCompactNotificationText(p.snapshot, {}) + ); notificationActive = true; revision = p.snapshot.revision; pending = null; @@ -173,7 +179,8 @@ export const androidSink: GlanceableSink = { if (notificationActive && snapshot.revision > revision) { updateLiveUpdate( translate(NOTIFICATION_TITLE_KEY), - buildOngoingNotificationText(snapshot, {}, translate) + buildOngoingNotificationText(snapshot, {}, translate), + buildCompactNotificationText(snapshot, {}) ); revision = snapshot.revision; } diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts index 4d6028fdcd..0aaca589d6 100644 --- a/apps/mobile/src/glanceable-android/live-update.ts +++ b/apps/mobile/src/glanceable-android/live-update.ts @@ -8,8 +8,8 @@ import { requireOptionalNativeModule } from 'expo'; type LiveUpdateNativeModule = { isPromotionCapable(): boolean; - start(title: string, text: string, promotion: boolean): void; - update(title: string, text: string, promotion: boolean): void; + start(title: string, text: string, compactText: string | null, promotion: boolean): void; + update(title: string, text: string, compactText: string | null, promotion: boolean): void; end(): void; }; @@ -23,12 +23,12 @@ function isPromotionCapable(): boolean { return nativeModule?.isPromotionCapable() ?? false; } -export function start(title: string, text: string): void { - nativeModule?.start(title, text, isPromotionCapable()); +export function start(title: string, text: string, compactText: string | null): void { + nativeModule?.start(title, text, compactText, isPromotionCapable()); } -export function update(title: string, text: string): void { - nativeModule?.update(title, text, isPromotionCapable()); +export function update(title: string, text: string, compactText: string | null): void { + nativeModule?.update(title, text, compactText, isPromotionCapable()); } export function end(): void { diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts index eedc335871..72e9ec6d60 100644 --- a/apps/mobile/src/glanceable-android/widget-props.test.ts +++ b/apps/mobile/src/glanceable-android/widget-props.test.ts @@ -4,11 +4,27 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { describe, expect, it } from 'vitest'; -import { buildAndroidWidgetProps, buildOngoingNotificationText } from './widget-props'; +import { + buildAndroidWidgetProps, + buildCompactNotificationText, + buildOngoingNotificationText, +} from './widget-props'; const NOW = 1_750_000_000_000; -const translate = (key: string): string => key; +const COPY: Record = { + 'glanceable.needsInput': 'Needs input', + 'glanceable.reconnecting': 'Reconnecting', + 'glanceable.running': 'Running', + 'glanceable.waiting': 'Waiting for agents', + 'glanceable.empty': 'No work in progress', + 'glanceable.stale': 'Updates delayed', + 'glanceable.expired': 'Status expired', + 'glanceable.signedOut': 'Sign in to see agents', + 'glanceable.privacy': 'Agents hidden', + 'glanceable.openAgents': 'Open agents', +}; +const translate = (key: string): string => COPY[key] ?? key; function snapshotFor( sessions: { status: string }[], @@ -25,25 +41,36 @@ function snapshotFor( }); } +const MIXED = { + ...snapshotFor([], 0, 'happy'), + needsInput: 2, + reconnecting: 3, + running: 4, +}; + describe('buildAndroidWidgetProps', () => { - it('ranks the compact primary count as needs-input, then reconnecting, then running', () => { - const props = buildAndroidWidgetProps( - snapshotFor( - [{ status: 'busy' }, { status: 'busy' }, { status: 'retry' }, { status: 'question' }], - 0 - ), - {}, - translate - ); - expect(props.primaryLabel).toBe('glanceable.needsInput'); - expect(props.primaryCount).toBe(1); - expect(props.countLines.map(line => line.label)).toEqual([ - 'glanceable.needsInput', - 'glanceable.reconnecting', - 'glanceable.running', + it('ranks the compact primary count and keeps all expanded numeric counts', () => { + const props = buildAndroidWidgetProps(MIXED, {}, translate); + expect(props.primaryLabel).toBe('Needs input'); + expect(props.primaryCount).toBe(2); + expect(props.countLines).toEqual([ + { label: 'Needs input', count: 2 }, + { label: 'Reconnecting', count: 3 }, + { label: 'Running', count: 4 }, ]); }); + it.each([ + ['happy', '2 Needs input, 3 Reconnecting, 4 Running, Open agents'], + ['stale', 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents'], + ] as const)( + 'includes numeric counts and the action in the %s spoken label', + (status, expected) => { + const props = buildAndroidWidgetProps({ ...MIXED, status }, {}, translate); + expect(props.accessibilityLabel).toBe(expected); + } + ); + it('applies the locked copy matrix per status', () => { const cases: [ GlanceableAgentsSnapshot['status'], @@ -52,11 +79,12 @@ describe('buildAndroidWidgetProps', () => { number, boolean, ][] = [ - ['empty', [], 'glanceable.empty', 0, false], - ['stale', [{ status: 'busy' }], 'glanceable.stale', 1, true], - ['expired', [], 'glanceable.expired', 0, false], - ['signed_out', [], 'glanceable.signedOut', 0, false], - ['privacy', [], 'glanceable.privacy', 0, false], + ['waiting', [], 'Waiting for agents', 0, false], + ['empty', [], 'No work in progress', 0, false], + ['stale', [{ status: 'busy' }], 'Updates delayed', 1, true], + ['expired', [], 'Status expired', 0, false], + ['signed_out', [], 'Sign in to see agents', 0, false], + ['privacy', [], 'Agents hidden', 0, false], ]; for (const [status, sessions, statusLine, counts, showOpenAgents] of cases) { const props = buildAndroidWidgetProps(snapshotFor(sessions, 0, status), {}, translate); @@ -97,19 +125,77 @@ describe('buildAndroidWidgetProps', () => { }); describe('buildOngoingNotificationText', () => { - it('lists ranked counts for happy and stale, otherwise the locked copy', () => { - const happy = snapshotFor([{ status: 'busy' }, { status: 'question' }], 0); - expect(buildOngoingNotificationText(happy, {}, translate)).toBe( - '1 glanceable.needsInput, 1 glanceable.running' + it('lists every ranked numeric count for happy work', () => { + expect(buildOngoingNotificationText(MIXED, {}, translate)).toBe( + '2 Needs input, 3 Reconnecting, 4 Running' ); + }); - const stale = snapshotFor([{ status: 'retry' }], 0, 'stale'); - expect(buildOngoingNotificationText(stale, {}, translate)).toBe('1 glanceable.reconnecting'); + it('adds the translated stale warning without losing eligible counts', () => { + expect(buildOngoingNotificationText({ ...MIXED, status: 'stale' }, {}, translate)).toBe( + 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running' + ); + }); + + it('keeps stale copy when there are no retained counts', () => { + expect(buildOngoingNotificationText(snapshotFor([], 0, 'stale'), {}, translate)).toBe( + 'Updates delayed' + ); + }); - const empty = snapshotFor([], 0, 'empty'); - expect(buildOngoingNotificationText(empty, {}, translate)).toBe('glanceable.empty'); + it('uses empty copy when there is no eligible work', () => { + expect(buildOngoingNotificationText(snapshotFor([]), {}, translate)).toBe( + 'No work in progress' + ); + }); +}); + +describe('buildCompactNotificationText', () => { + it.each([ + { needsInput: 2, reconnecting: 3, running: 4, expected: '2' }, + { needsInput: 0, reconnecting: 3, running: 4, expected: '3' }, + { needsInput: 0, reconnecting: 0, running: 4, expected: '4' }, + { needsInput: 0, reconnecting: 0, running: 0, expected: null }, + ])('uses the ranked primary number $expected, not the total or full summary', counts => { + const snapshot = { ...MIXED, ...counts }; + expect(buildCompactNotificationText(snapshot, {})).toBe(counts.expected); + expect(buildCompactNotificationText({ ...snapshot, status: 'stale' }, {})).toBe( + counts.expected + ); + }); +}); + +describe('status precedence and count hiding', () => { + it.each([ + ['waiting', 'Waiting for agents'], + ['empty', 'No work in progress'], + ['expired', 'Status expired'], + ['signed_out', 'Sign in to see agents'], + ['privacy', 'Agents hidden'], + ] as const)('hides counts on every Android surface for %s', (status, expected) => { + const snapshot = { ...MIXED, status }; + const props = buildAndroidWidgetProps(snapshot, {}, translate); + expect(props.statusLine).toBe(expected); + expect(props.countLines).toEqual([]); + expect(props.primaryLabel).toBeNull(); + expect(props.primaryCount).toBe(0); + expect(props.showOpenAgents).toBe(false); + expect(buildOngoingNotificationText(snapshot, {}, translate)).toBe(expected); + expect(buildCompactNotificationText(snapshot, {})).toBeNull(); + }); - const privacy = snapshotFor([], 0, 'privacy'); - expect(buildOngoingNotificationText(privacy, {}, translate)).toBe('glanceable.privacy'); + it.each([ + [{ signedOut: true, orgInvalid: true }, 'Sign in to see agents'], + [{ orgInvalid: true }, 'Agents hidden'], + ] as const)('honors auth overrides before stale counts: %j', (flags, expected) => { + const snapshot = { ...MIXED, status: 'stale' as const }; + const props = buildAndroidWidgetProps(snapshot, flags, translate); + expect(props.statusLine).toBe(expected); + expect(props.countLines).toEqual([]); + expect(props.primaryLabel).toBeNull(); + expect(props.primaryCount).toBe(0); + expect(props.showOpenAgents).toBe(false); + expect(buildOngoingNotificationText(snapshot, flags, translate)).toBe(expected); + expect(buildCompactNotificationText(snapshot, flags)).toBeNull(); }); }); diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts index 79674d515c..01edee2a1a 100644 --- a/apps/mobile/src/glanceable-android/widget-props.ts +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -2,7 +2,7 @@ import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-a import { glanceableCountLines, - glanceableSpokenLabelKeys, + glanceableSpokenLabel, glanceableStatusCopyKey, type GlanceableSurfaceFlags, primaryGlanceableCount, @@ -42,21 +42,20 @@ export function buildAndroidWidgetProps( ): AndroidWidgetProps { const status = resolveGlanceableStatus(snapshot, flags); const statusKey = glanceableStatusCopyKey(snapshot, flags); - const primary = primaryGlanceableCount(snapshot); + const showCounts = status === 'happy' || status === 'stale'; + const primary = showCounts ? primaryGlanceableCount(snapshot) : null; return { statusLine: statusKey === null ? null : translate(statusKey), - countLines: glanceableCountLines(snapshot).map(line => ({ + countLines: (showCounts ? glanceableCountLines(snapshot) : []).map(line => ({ label: translate(line.key), count: line.count, })), primaryLabel: primary === null ? null : translate(primary.key), primaryCount: primary === null ? 0 : primary.count, openAgentsLabel: translate('glanceable.openAgents'), - showOpenAgents: status === 'happy' || status === 'stale', - accessibilityLabel: glanceableSpokenLabelKeys(snapshot, flags) - .map(key => translate(key)) - .join(', '), + showOpenAgents: showCounts, + accessibilityLabel: glanceableSpokenLabel(snapshot, flags, translate), }; } @@ -94,9 +93,8 @@ export function buildGenericWidgetProps(translate: (key: string) => string): And } /** - * Single-line summary for the ongoing notification: ranked counts for happy and - * stale, otherwise the locked status copy. Built only from translated keys, so it - * never leaks a title, organization name, or id. + * Ongoing notification: every ranked count, with a warning when stale, otherwise + * the locked status copy. Never a title, organization name, or id. */ export function buildOngoingNotificationText( snapshot: GlanceableAgentsSnapshot, @@ -107,8 +105,22 @@ export function buildOngoingNotificationText( if (status === 'happy' || status === 'stale') { const lines = glanceableCountLines(snapshot); if (lines.length > 0) { - return lines.map(line => `${line.count} ${translate(line.key)}`).join(', '); + const counts = lines.map(line => `${line.count} ${translate(line.key)}`).join(', '); + return status === 'stale' ? `${translate('glanceable.stale')}, ${counts}` : counts; } } return translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty'); } + +/** The promoted chip shows only the primary number; the full text keeps all labels. */ +export function buildCompactNotificationText( + snapshot: GlanceableAgentsSnapshot, + flags: GlanceableSurfaceFlags +): string | null { + const status = resolveGlanceableStatus(snapshot, flags); + if (status !== 'happy' && status !== 'stale') { + return null; + } + const primary = primaryGlanceableCount(snapshot); + return primary === null ? null : String(primary.count); +} diff --git a/apps/mobile/src/lib/glanceable/presentation.test.ts b/apps/mobile/src/lib/glanceable/presentation.test.ts index 87177b1294..c3112cab74 100644 --- a/apps/mobile/src/lib/glanceable/presentation.test.ts +++ b/apps/mobile/src/lib/glanceable/presentation.test.ts @@ -6,6 +6,7 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { + glanceableSpokenLabel, glanceableSpokenLabelKeys, glanceableStatusCopyKey, primaryGlanceableCount, @@ -106,3 +107,63 @@ describe('spoken label shape', () => { ]); }); }); + +describe('numeric spoken label', () => { + const copy: Record = { + 'glanceable.needsInput': 'Needs input', + 'glanceable.reconnecting': 'Reconnecting', + 'glanceable.running': 'Running', + 'glanceable.waiting': 'Waiting for agents', + 'glanceable.empty': 'No work in progress', + 'glanceable.stale': 'Updates delayed', + 'glanceable.expired': 'Status expired', + 'glanceable.signedOut': 'Sign in to see agents', + 'glanceable.privacy': 'Agents hidden', + 'glanceable.openAgents': 'Open agents', + }; + const translate = (key: string): string => copy[key] ?? key; + const mixed = { + ...snapshot({ status: 'happy' }), + needsInput: 2, + reconnecting: 3, + running: 4, + }; + + it('speaks each numeric count in rank order before Open agents', () => { + expect(glanceableSpokenLabel(mixed, {}, translate)).toBe( + '2 Needs input, 3 Reconnecting, 4 Running, Open agents' + ); + }); + + it('speaks the translated stale warning before retained numeric counts', () => { + expect(glanceableSpokenLabel({ ...mixed, status: 'stale' }, {}, translate)).toBe( + 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents' + ); + }); + + it('speaks stale copy without inventing counts when none remain', () => { + expect(glanceableSpokenLabel(snapshot({ status: 'stale' }), {}, translate)).toBe( + 'Updates delayed, Open agents' + ); + }); + + it.each([ + ['waiting', 'Waiting for agents, Open agents'], + ['empty', 'No work in progress, Open agents'], + ['expired', 'Status expired, Open agents'], + ['signed_out', 'Sign in to see agents, Open agents'], + ['privacy', 'Agents hidden, Open agents'], + ] as const)('hides numeric counts when the status is %s', (status, expected) => { + expect(glanceableSpokenLabel({ ...mixed, status }, {}, translate)).toBe(expected); + }); + + it('keeps signed-out and privacy overrides ahead of stale counts', () => { + const stale = { ...mixed, status: 'stale' as const }; + expect(glanceableSpokenLabel(stale, { signedOut: true, orgInvalid: true }, translate)).toBe( + 'Sign in to see agents, Open agents' + ); + expect(glanceableSpokenLabel(stale, { orgInvalid: true }, translate)).toBe( + 'Agents hidden, Open agents' + ); + }); +}); diff --git a/apps/mobile/src/lib/glanceable/presentation.ts b/apps/mobile/src/lib/glanceable/presentation.ts index 5935d60d46..4d533ad2b6 100644 --- a/apps/mobile/src/lib/glanceable/presentation.ts +++ b/apps/mobile/src/lib/glanceable/presentation.ts @@ -105,3 +105,23 @@ export function glanceableSpokenLabelKeys( parts.push('glanceable.openAgents'); return parts; } + +/** Translated status, numeric counts in rank order, then the Open agents action. */ +export function glanceableSpokenLabel( + snapshot: GlanceableAgentsSnapshot, + flags: GlanceableSurfaceFlags, + translate: (key: string) => string +): string { + const status = resolveGlanceableStatus(snapshot, flags); + const parts: string[] = []; + if (status !== 'happy') { + parts.push(translate(GLANCEABLE_STATUS_COPY_KEY[status])); + } + if (status === 'happy' || status === 'stale') { + for (const { key, count } of glanceableCountLines(snapshot)) { + parts.push(`${count} ${translate(key)}`); + } + } + parts.push(translate('glanceable.openAgents')); + return parts.join(', '); +} From 0f036a4a9aa92cc42e97cfb2d40936d9cd2d7c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 15:59:38 +0200 Subject: [PATCH 6/6] fix(mobile): persist Android glanceable deadlines natively --- .../android/src/main/AndroidManifest.xml | 13 ++ .../ActiveAgentsDeadlineReceiver.kt | 124 ++++++++++ .../ActiveAgentsLiveUpdateModule.kt | 48 +++- .../glanceable-android/android-sink.test.ts | 213 +++++++++++++++--- .../src/glanceable-android/android-sink.ts | 122 +++++----- .../src/glanceable-android/live-update.ts | 51 ++++- .../src/glanceable-android/register.test.ts | 109 ++++++++- .../mobile/src/glanceable-android/register.ts | 31 ++- .../glanceable-android/widget-props.test.ts | 40 +++- .../src/glanceable-android/widget-props.ts | 17 +- 10 files changed, 645 insertions(+), 123 deletions(-) create mode 100644 apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml b/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7083a574a0 --- /dev/null +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt new file mode 100644 index 0000000000..d64d9758a7 --- /dev/null +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt @@ -0,0 +1,124 @@ +package com.kilocode.activeagentsliveupdate + +import android.app.AlarmManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import java.util.UUID + +/** One OS-owned widget deadline; an old delivery never changes a newer snapshot. */ +class ActiveAgentsDeadlineReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> restoreWidgetDeadline(context) + else -> expire(context, intent) + } + } + + companion object { + private const val STORE = "active-agents-deadlines" + private const val SNAPSHOT = "widget-snapshot" + private const val WIDGET = "widget-expiry" + private const val NOTIFICATION = "notification-expiry" + private const val GENERATION = "generation" + private const val DEADLINE = "deadline" + internal const val NOTIFICATION_ID = 1001 + + @Synchronized + fun setWidgetSnapshot(context: Context, snapshot: String, expiresAt: Long) { + replace(context, WIDGET, expiresAt, snapshot) + } + + fun getWidgetSnapshot(context: Context): String? = + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).getString(SNAPSHOT, null) + + /** Notification.Builder.setTimeoutAfter is unavailable on supported API 24–25. */ + @Synchronized + fun setLegacyNotificationTimeout(context: Context, timeoutMs: Long) { + val deadline = if (timeoutMs > 0) System.currentTimeMillis() + timeoutMs else 0 + replace(context, NOTIFICATION, deadline) + } + + private fun replace(context: Context, action: String, deadline: Long, snapshot: String? = null) { + val generation = UUID.randomUUID().toString() + val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + val editor = preferences.edit() + .putLong(action, deadline) + .putString("$action-$GENERATION", generation) + if (snapshot != null) editor.putString(SNAPSHOT, snapshot) + // Commit before returning across the bridge so process exit cannot lose a blank. + check(editor.commit()) { "Cannot persist the active agents deadline" } + + val intent = Intent(context, ActiveAgentsDeadlineReceiver::class.java) + .setAction(action) + .putExtra(DEADLINE, deadline) + .putExtra(GENERATION, generation) + val operation = PendingIntent.getBroadcast( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val alarms = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarms.cancel(operation) + if (deadline <= System.currentTimeMillis()) { + operation.cancel() + return + } + if (Build.VERSION.SDK_INT >= 31) { + // No exact-alarm permission: Android can defer delivery while idle. + alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, deadline, operation) + } else { + alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, deadline, operation) + } + } + + @Synchronized + private fun restoreWidgetDeadline(context: Context) { + val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + val deadline = preferences.getLong(WIDGET, 0) + // Privacy and signed-out snapshots persist a zero deadline in the same commit. + if (deadline <= 0) return + // Keep the original expiry and snapshot; replace only the alarm generation. + replace(context, WIDGET, deadline) + // Also handle an expiry that passed while down or during alarm restoration. + expire(context, Intent(WIDGET) + .putExtra(DEADLINE, deadline) + .putExtra(GENERATION, preferences.getString("$WIDGET-$GENERATION", null))) + } + + @Synchronized + private fun expire(context: Context, intent: Intent) { + val action = intent.action ?: return + if (action != WIDGET && action != NOTIFICATION) return + val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + val deadline = preferences.getLong(action, 0) + if (deadline <= 0 || deadline > System.currentTimeMillis() || + deadline != intent.getLongExtra(DEADLINE, 0) || + preferences.getString("$action-$GENERATION", null) != intent.getStringExtra(GENERATION) + ) return + + check(preferences.edit().remove(action).remove("$action-$GENERATION").commit()) { + "Cannot consume the active agents deadline" + } + if (action == NOTIFICATION) { + val notifications = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notifications.cancel(NOTIFICATION_ID) + return + } + + // The installed widget provider starts its durable headless worker for each instance. + // That handler re-reads the stored snapshot, including any intervening privacy blank. + val provider = ComponentName(context.packageName, "${context.packageName}.widget.ActiveAgentsWidget") + val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + if (ids.isEmpty()) return + context.sendBroadcast( + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + .setComponent(provider) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + ) + } + } +} diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 7b1a4cc422..df0132a7c9 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -27,16 +27,24 @@ class ActiveAgentsLiveUpdateModule : Module() { } Function("start") { title: String, text: String, compactText: String?, promotion: Boolean -> - post(title, text, compactText, promotion) + post(title, text, compactText, promotion, 0) } - Function("update") { title: String, text: String, compactText: String?, promotion: Boolean -> - post(title, text, compactText, promotion) + Function("update") { title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Double -> + post(title, text, compactText, promotion, timeoutMs.toLong()) } Function("end") { dismiss() } + + Function("setWidgetSnapshot") { snapshot: String, expiresAt: Double -> + ActiveAgentsDeadlineReceiver.setWidgetSnapshot(context, snapshot, expiresAt.toLong()) + } + + Function("getWidgetSnapshot") { + ActiveAgentsDeadlineReceiver.getWidgetSnapshot(context) + } } private val context: Context @@ -45,6 +53,9 @@ class ActiveAgentsLiveUpdateModule : Module() { private val notificationManager: NotificationManager get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationState + get() = context.getSharedPreferences("active_agents_notification", Context.MODE_PRIVATE) + private fun smallIconId(): Int = context.resources.getIdentifier("notification_icon", "drawable", context.packageName) @@ -91,7 +102,7 @@ class ActiveAgentsLiveUpdateModule : Module() { ) } - private fun post(title: String, text: String, compactText: String?, promotion: Boolean) { + private fun post(title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Long) { val builder = newBuilder(title) .setSmallIcon(smallIconId()) .setContentTitle(title) @@ -110,16 +121,39 @@ class ActiveAgentsLiveUpdateModule : Module() { builder.setStyle(Notification.ProgressStyle()) } - notificationManager.notify(NOTIFICATION_ID, builder.build()) + // Commit before arming a timeout so process exit cannot lose cancellation state. + if (timeoutMs > 0) { + check(notificationState.edit().putBoolean(HAS_TIMEOUT, true).commit()) { + "Cannot persist the active agents notification timeout" + } + } + + if (Build.VERSION.SDK_INT >= 26) { + // Ordinary updates must retain the notification so onlyAlertOnce suppresses repeat alerts. + if (timeoutMs <= 0 && notificationState.getBoolean(HAS_TIMEOUT, false)) { + notificationManager.cancel(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID) + } + builder.setTimeoutAfter(timeoutMs.coerceAtLeast(0)) + } else { + ActiveAgentsDeadlineReceiver.setLegacyNotificationTimeout(context, timeoutMs) + } + notificationManager.notify(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID, builder.build()) + if (timeoutMs <= 0) { + notificationState.edit().putBoolean(HAS_TIMEOUT, false).apply() + } } private fun dismiss() { - notificationManager.cancel(NOTIFICATION_ID) + if (Build.VERSION.SDK_INT < 26) { + ActiveAgentsDeadlineReceiver.setLegacyNotificationTimeout(context, 0) + } + notificationManager.cancel(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID) + notificationState.edit().remove(HAS_TIMEOUT).apply() } private companion object { + const val HAS_TIMEOUT = "has_timeout" const val CHANNEL_ID = "active-agents" - const val NOTIFICATION_ID = 1001 const val OPEN_AGENTS_DEEP_LINK = "kiloapp:///cloud/sessions" const val OPEN_AGENTS_REQUEST_CODE = 1002 } diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index 911b441de1..cc46007c49 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -24,9 +24,21 @@ const mocks = vi.hoisted(() => { promotion: boolean; } | null = null; - // eslint-disable-next-line max-params -- the fake mirrors the four positional native bridge arguments - function post(title: string, text: string, compactText: string | null, promotion: boolean): void { + // Capture the requested bridge timeout, not Android's alarm cancellation behavior. + let notificationDeadline: number | null = null; + let widgetSnapshot: string | null = null; + let widgetDeadline = 0; + + // eslint-disable-next-line max-params -- the fake models the native bridge's timeout argument + function post( + title: string, + text: string, + compactText: string | null, + promotion: boolean, + timeoutMs = 0 + ): void { notification = { title, text, compactText, promotion }; + notificationDeadline = timeoutMs > 0 ? Date.now() + timeoutMs : null; } return { @@ -36,9 +48,17 @@ const mocks = vi.hoisted(() => { update: vi.fn(post), end: vi.fn(() => { notification = null; + notificationDeadline = null; }), + setWidgetSnapshot: vi.fn((snapshot: string, deadline: number) => { + widgetSnapshot = snapshot; + widgetDeadline = deadline; + }), + getWidgetSnapshot: () => widgetSnapshot, }, getNotification: () => notification, + getRequestedNotificationDeadline: () => notificationDeadline, + getWidgetDeadline: () => widgetDeadline, requestWidgetUpdate: vi.fn(), alert: vi.fn(), }; @@ -112,6 +132,9 @@ function deferredPermission(): { } beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + mocks.native.setWidgetSnapshot('', 0); _resetAndroidSinkForTests(); _resetAndroidPermissionAlertForTests(); // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules @@ -285,24 +308,25 @@ describe('androidSink widget publish and end', () => { ); }); - it('blanks with privacy copy and dismisses the notification on end', async () => { - androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX); + it.each([ + ['privacy', 'Agents hidden'], + ['signed_out', 'Sign in to see agents'], + ] as const)('cancels both deadlines immediately for %s', async (status, copy) => { + androidSink.publish(MIXED); + androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.native.start).toHaveBeenCalledTimes(1); + androidSink.publish(snapshotFor([], 1, 'empty')); + expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); - androidSink.publish(snapshotFor([], 1, 'privacy')); - expect(getCurrentWidgetProps()?.statusLine).toBe('Agents hidden'); + androidSink.publish(snapshotFor([], 2, status)); + expect(mocks.getNotification()).toBeNull(); + expect(mocks.getRequestedNotificationDeadline()).toBeNull(); + expect(mocks.getWidgetDeadline()).toBe(0); + vi.setSystemTime(NOW + 28_800_001); + expect(getCurrentWidgetProps()?.statusLine).toBe(copy); expect(getCurrentWidgetProps()?.countLines).toEqual([]); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: 'Agents hidden', - compactText: null, - promotion: true, - }); - androidSink.endImmediate(); expect(mocks.getNotification()).toBeNull(); - expect(mocks.native.end).toHaveBeenCalledTimes(1); }); it('does not update the notification from publish before it has started', () => { @@ -328,26 +352,153 @@ describe('androidSink widget publish and end', () => { expect(getCurrentWidgetProps()?.primaryCount).toBe(1); }); - it('schedules a single future redraw at expiresAt with expired copy', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW); - const snapshot = snapshotFor([{ status: 'busy' }], 0); + it.each(['happy', 'stale'] as const)( + 'hands Android the original %s expiry without a JS timer', + status => { + const snapshot = snapshotFor([{ status: 'busy' }], 0, status); + androidSink.publish(snapshot); + expect(mocks.getWidgetDeadline()).toBe(NOW + 28_800_000); + expect(vi.getTimerCount()).toBe(0); + + vi.setSystemTime(NOW + 28_799_999); + expect(getCurrentWidgetProps()?.primaryCount).toBe(1); + vi.setSystemTime(NOW + 28_800_000); + expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired'); + expect(getCurrentWidgetProps()?.countLines).toEqual([]); + expect(getCurrentWidgetProps()?.primaryCount).toBe(0); + expect(getCurrentWidgetProps()?.showOpenAgents).toBe(false); + } + ); - androidSink.publish(snapshot); - expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(1); + it('replaces the old widget deadline and keeps it when only the notification ends', () => { + androidSink.publish(MIXED); + const newer = buildGlanceableSnapshot({ + sessions: [{ status: 'busy' }], + userId: 'u1', + organizationId: null, + now: NOW + 60_000, + previousRevision: MIXED.revision, + }); + androidSink.publish(newer); + androidSink.endImmediate(); + expect(mocks.getWidgetDeadline()).toBe(NOW + 28_860_000); + vi.setSystemTime(NOW + 28_800_000); + expect(getCurrentWidgetProps()?.primaryCount).toBe(1); + expect(mocks.getNotification()).toBeNull(); + }); - vi.advanceTimersByTime(28_800_000); - expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(2); + it('does not extend the successful deadline when stale data is published later', () => { + androidSink.publish(MIXED); + vi.setSystemTime(NOW + 60_000); + androidSink.publish({ ...MIXED, status: 'stale', revision: 2 }); + expect(mocks.getWidgetDeadline()).toBe(NOW + 28_800_000); + expect(getCurrentWidgetProps()?.primaryCount).toBe(2); + vi.setSystemTime(NOW + 28_800_000); + expect(getCurrentWidgetProps()?.countLines).toEqual([]); + }); - const secondCall = mocks.requestWidgetUpdate.mock.calls[1]?.[0] as - | { renderWidget?: unknown } - | undefined; - expect(typeof secondCall?.renderWidget).toBe('function'); + it('passes an eight-second terminal timeout that survives clearing JS state', async () => { + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + androidSink.publish(snapshotFor([], 1, 'empty')); + _resetAndroidSinkForTests(); - expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired'); - expect(getCurrentWidgetProps()?.countLines).toEqual([]); - expect(getCurrentWidgetProps()?.primaryCount).toBe(0); - expect(getCurrentWidgetProps()?.showOpenAgents).toBe(false); + expect(mocks.getNotification()).toMatchObject({ + text: 'No work in progress', + compactText: null, + }); + expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); + expect(mocks.getWidgetDeadline()).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('keeps the first terminal deadline across later empty updates', async () => { + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + androidSink.publish(snapshotFor([], 1, 'empty')); + vi.setSystemTime(NOW + 4000); + androidSink.publish(snapshotFor([], 2, 'empty')); + expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); + vi.setSystemTime(NOW + 8000); + androidSink.publish(snapshotFor([], 3, 'empty')); + expect(mocks.getNotification()).toBeNull(); + }); + + it('allows the same terminal revision to retry after a native post rejects', async () => { + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + const empty = snapshotFor([], 1, 'empty'); + mocks.native.update.mockImplementationOnce(() => { + throw new Error('Cannot persist the active agents notification timeout'); + }); + + expect(() => { + androidSink.publish(empty); + }).toThrow('Cannot persist the active agents notification timeout'); + expect(mocks.getNotification()?.text).toBe('2 Needs input, 3 Reconnecting, 4 Running'); + expect(mocks.getRequestedNotificationDeadline()).toBeNull(); + + vi.setSystemTime(NOW + 3000); + androidSink.publish(empty); + expect(mocks.getNotification()).toMatchObject({ + text: 'No work in progress', + compactText: null, + }); + expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); + }); + + it.each(['publish', 'startOrUpdate', 'JS reload'] as const)( + 'requests untimed eligible work through %s after terminal copy', + async method => { + androidSink.publish(MIXED); + androidSink.startOrUpdate(MIXED, CTX); + await flushAsync(); + androidSink.publish(snapshotFor([], 1, 'empty')); + expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); + vi.setSystemTime(NOW + 4000); + if (method === 'JS reload') { + _resetAndroidSinkForTests(); + } + const newer = { ...MIXED, revision: 3 }; + if (method === 'publish') { + androidSink.publish(newer); + } else { + androidSink.startOrUpdate(newer, CTX); + await flushAsync(); + } + + expect(mocks.getRequestedNotificationDeadline()).toBeNull(); + expect(mocks.getNotification()).toMatchObject({ + text: '2 Needs input, 3 Reconnecting, 4 Running', + compactText: '2', + }); + expect(mocks.getWidgetDeadline()).toBe(method === 'publish' ? NOW + 28_800_000 : 0); + } + ); + + it('rejects a pending start when its successful snapshot expires', async () => { + const deferred = deferredPermission(); + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- the test controls permission resolution + _setPermissionReaderForTests(() => deferred.promise); + androidSink.startOrUpdate(MIXED, CTX); + vi.setSystemTime(NOW + 28_800_000); + deferred.resolve('granted'); + await flushAsync(); + + expect(mocks.getNotification()).toBeNull(); + }); + + it('rejects a pending start after an empty snapshot cancels the work', async () => { + const deferred = deferredPermission(); + // eslint-disable-next-line promise-function-async, prefer-await-to-then -- the test controls permission resolution + _setPermissionReaderForTests(() => deferred.promise); + androidSink.startOrUpdate(MIXED, CTX); + androidSink.publish(snapshotFor([], 1, 'empty')); + deferred.resolve('granted'); + await flushAsync(); + + expect(mocks.getNotification()).toBeNull(); + expect(mocks.getWidgetDeadline()).toBe(0); }); }); diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index e147d199ca..652ed29d64 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -1,4 +1,5 @@ import { + GLANCEABLE_TERMINAL_MS, type GlanceableAgentsSnapshot, isEligibleGlanceableWork, } from '@kilocode/app-shared/glanceable-agents-snapshot'; @@ -10,6 +11,7 @@ import { type GlanceableSink, type GlanceableSinkContext } from '@/lib/glanceabl import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget'; import { end as endLiveUpdate, + setWidgetSnapshot, start as startLiveUpdate, update as updateLiveUpdate, } from './live-update'; @@ -17,66 +19,58 @@ import { isNotificationPermissionGranted } from './permission'; import { showAndroidPermissionAlertOnce } from './permission-alert'; import { type AndroidWidgetProps, - buildAndroidWidgetProps, buildCompactNotificationText, - buildExpiredWidgetProps, + buildCurrentWidgetProps, buildOngoingNotificationText, } from './widget-props'; /** - * Android sink: one ongoing notification plus the resizable Home widget. The - * widget renders from the last published snapshot; the notification starts on - * the first eligible emit (after a permission check) and updates one fixed id. - * Ended notifications never clear the widget so the Home surface stays truthful. + * Android owns the widget expiry and notification timeout. The sink supplies + * translated copy, persists the latest snapshot, and fences pending starts. + * Ending the ongoing notification never cancels a still-eligible widget expiry. */ - -type TimerHandle = ReturnType; - const NOTIFICATION_TITLE_KEY = 'glanceable.channelName'; function translate(key: string): string { return i18n.t(key); } -let lastWidgetProps: AndroidWidgetProps | null = null; +let lastWidgetSnapshot: GlanceableAgentsSnapshot | null = null; let notificationActive = false; let revision = 0; let pending: { snapshot: GlanceableAgentsSnapshot; ctx: GlanceableSinkContext } | null = null; -let expiryTimer: TimerHandle | null = null; let startEpoch = 0; +let terminalExpiresAt: number | null = null; -/** The last published widget props; the task handler renders a fresh redraw from it. */ +/** A delayed render must check the current snapshot and its deadline, not cached props. */ export function getCurrentWidgetProps(): AndroidWidgetProps | null { - return lastWidgetProps; + return lastWidgetSnapshot === null + ? null + : buildCurrentWidgetProps(lastWidgetSnapshot, translate); } function renderWidgetNow(props: AndroidWidgetProps): void { void requestWidgetUpdate({ widgetName: WIDGET_NAME, - renderWidget: info => renderActiveAgentsWidget(props, info), + renderWidget: info => renderActiveAgentsWidget(getCurrentWidgetProps() ?? props, info), }); } -function clearExpiryTimer(): void { - if (expiryTimer !== null) { - clearTimeout(expiryTimer); - expiryTimer = null; - } +function hasCurrentWork(snapshot: GlanceableAgentsSnapshot): boolean { + return ( + (snapshot.status === 'happy' || snapshot.status === 'stale') && + isEligibleGlanceableWork(snapshot) && + Date.parse(snapshot.expiresAt) > Date.now() + ); } -/** One future redraw at expiresAt (no per-minute timer) that hides the counts. */ -function scheduleExpiryRedraw(snapshot: GlanceableAgentsSnapshot): void { - clearExpiryTimer(); - const delay = Date.parse(snapshot.expiresAt) - Date.now(); - if (delay <= 0) { - return; - } - const expiredProps = buildExpiredWidgetProps(snapshot, translate); - expiryTimer = setTimeout(() => { - expiryTimer = null; - lastWidgetProps = expiredProps; - renderWidgetNow(expiredProps); - }, delay); +function endNotification(): void { + endLiveUpdate(); + notificationActive = false; + revision = 0; + pending = null; + startEpoch += 1; + terminalExpiresAt = null; } /** @@ -87,7 +81,7 @@ async function tryStartOrUpdate( snapshot: GlanceableAgentsSnapshot, ctx: GlanceableSinkContext ): Promise { - if (!isEligibleGlanceableWork(snapshot)) { + if (!hasCurrentWork(snapshot)) { pending = null; return; } @@ -100,13 +94,14 @@ async function tryStartOrUpdate( if (notificationActive) { updateLiveUpdate(title, text, compactText); + terminalExpiresAt = null; revision = snapshot.revision; return; } const epoch = startEpoch; const granted = await isNotificationPermissionGranted(); - if (epoch !== startEpoch) { + if (epoch !== startEpoch || !hasCurrentWork(snapshot)) { return; } if (granted) { @@ -114,12 +109,14 @@ async function tryStartOrUpdate( if (notificationActive) { if (snapshot.revision > revision) { updateLiveUpdate(title, text, compactText); + terminalExpiresAt = null; revision = snapshot.revision; } return; } startLiveUpdate(title, text, compactText); notificationActive = true; + terminalExpiresAt = null; revision = snapshot.revision; pending = null; return; @@ -130,7 +127,7 @@ async function tryStartOrUpdate( /** Retry a pending start after permission turns granted. Caller owns the check. */ function retryPendingStart(): void { const p = pending; - if (p === null || notificationActive || !isEligibleGlanceableWork(p.snapshot)) { + if (p === null || notificationActive || !hasCurrentWork(p.snapshot)) { return; } const title = translate(NOTIFICATION_TITLE_KEY); @@ -140,6 +137,7 @@ function retryPendingStart(): void { buildCompactNotificationText(p.snapshot, {}) ); notificationActive = true; + terminalExpiresAt = null; revision = p.snapshot.revision; pending = null; } @@ -162,25 +160,39 @@ export async function handleAppStateActive(): Promise { export const androidSink: GlanceableSink = { publish(snapshot) { - const props = buildAndroidWidgetProps(snapshot, {}, translate); - lastWidgetProps = props; + lastWidgetSnapshot = snapshot; + setWidgetSnapshot(snapshot); + const props = buildCurrentWidgetProps(snapshot, translate); renderWidgetNow(props); - scheduleExpiryRedraw(snapshot); - if (!isEligibleGlanceableWork(snapshot)) { + const eligible = hasCurrentWork(snapshot); + if (eligible) { + terminalExpiresAt = null; + } else { pending = null; - if (!notificationActive) { - // Dismiss a leftover native notification from a previous process. `end` - // cancels the fixed id, which is a no-op when nothing is posted. - endLiveUpdate(); + startEpoch += 1; + if ( + snapshot.status === 'privacy' || + snapshot.status === 'signed_out' || + !notificationActive + ) { + // Also dismiss the fixed native id after a JS restart, without starting an empty ongoing. + endNotification(); + return; + } + terminalExpiresAt ??= Date.now() + GLANCEABLE_TERMINAL_MS; + if (terminalExpiresAt <= Date.now()) { + endNotification(); + return; } } - // Mirror the newest revision onto an already-started notification so the - // empty/stale/privacy copy shows during the terminal window before end. if (notificationActive && snapshot.revision > revision) { updateLiveUpdate( translate(NOTIFICATION_TITLE_KEY), - buildOngoingNotificationText(snapshot, {}, translate), - buildCompactNotificationText(snapshot, {}) + eligible + ? buildOngoingNotificationText(snapshot, {}, translate) + : (props.statusLine ?? translate('glanceable.empty')), + eligible ? buildCompactNotificationText(snapshot, {}) : null, + terminalExpiresAt === null ? 0 : Math.max(1, terminalExpiresAt - Date.now()) ); revision = snapshot.revision; } @@ -190,23 +202,15 @@ export const androidSink: GlanceableSink = { void tryStartOrUpdate(snapshot, ctx); }, - endImmediate() { - clearExpiryTimer(); - endLiveUpdate(); - notificationActive = false; - revision = 0; - pending = null; - startEpoch += 1; - // Widget props intentionally kept: the Home widget stays truthful. - }, + endImmediate: endNotification, }; -/** Test-only: drop all sink state between cases. */ +/** Test-only: drop JS state without touching Android-owned storage or deadlines. */ export function _resetAndroidSinkForTests(): void { - lastWidgetProps = null; + lastWidgetSnapshot = null; notificationActive = false; revision = 0; pending = null; startEpoch += 1; - clearExpiryTimer(); + terminalExpiresAt = null; } diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts index 0aaca589d6..4859b65ce0 100644 --- a/apps/mobile/src/glanceable-android/live-update.ts +++ b/apps/mobile/src/glanceable-android/live-update.ts @@ -1,3 +1,8 @@ +import { + type GlanceableAgentsSnapshot, + glanceableAgentsSnapshotSchema, + isEligibleGlanceableWork, +} from '@kilocode/app-shared/glanceable-agents-snapshot'; import { requireOptionalNativeModule } from 'expo'; /** @@ -9,8 +14,16 @@ import { requireOptionalNativeModule } from 'expo'; type LiveUpdateNativeModule = { isPromotionCapable(): boolean; start(title: string, text: string, compactText: string | null, promotion: boolean): void; - update(title: string, text: string, compactText: string | null, promotion: boolean): void; + update( + title: string, + text: string, + compactText: string | null, + promotion: boolean, + timeoutMs: number + ): void; end(): void; + setWidgetSnapshot(snapshot: string, expiresAt: number): void; + getWidgetSnapshot(): string | null; }; const nativeModule = requireOptionalNativeModule('ActiveAgentsLiveUpdate'); @@ -27,10 +40,42 @@ export function start(title: string, text: string, compactText: string | null): nativeModule?.start(title, text, compactText, isPromotionCapable()); } -export function update(title: string, text: string, compactText: string | null): void { - nativeModule?.update(title, text, compactText, isPromotionCapable()); +// eslint-disable-next-line max-params -- translated bridge fields plus the native terminal timeout +export function update( + title: string, + text: string, + compactText: string | null, + timeoutMs = 0 +): void { + nativeModule?.update(title, text, compactText, isPromotionCapable(), timeoutMs); } export function end(): void { nativeModule?.end(); } + +/** Persist before rendering; the native receiver owns the single future expiry. */ +export function setWidgetSnapshot(snapshot: GlanceableAgentsSnapshot): void { + const expiresAt = Date.parse(snapshot.expiresAt); + const needsExpiry = + (snapshot.status === 'happy' || snapshot.status === 'stale') && + isEligibleGlanceableWork(snapshot) && + Number.isFinite(expiresAt) && + expiresAt > Date.now(); + nativeModule?.setWidgetSnapshot(JSON.stringify(snapshot), needsExpiry ? expiresAt : 0); +} + +/** Native storage is authoritative even when an obsolete headless task was already queued. */ +export function getStoredWidgetSnapshot(): GlanceableAgentsSnapshot | null { + const raw = nativeModule?.getWidgetSnapshot(); + if (raw == null) { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + const result = glanceableAgentsSnapshotSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } +} diff --git a/apps/mobile/src/glanceable-android/register.test.ts b/apps/mobile/src/glanceable-android/register.test.ts index 823fe6e73a..2978c7896e 100644 --- a/apps/mobile/src/glanceable-android/register.test.ts +++ b/apps/mobile/src/glanceable-android/register.test.ts @@ -6,11 +6,28 @@ import { isValidElement, type ReactNode } from 'react'; import { type WidgetRepresentation, type WidgetTaskHandler } from 'react-native-android-widget'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ - registerWidgetTaskHandler: vi.fn<(handler: WidgetTaskHandler) => void>(), -})); +const mocks = vi.hoisted(() => { + let snapshot: string | null = null; + let deadline = 0; + return { + registerWidgetTaskHandler: vi.fn<(handler: WidgetTaskHandler) => void>(), + native: { + setWidgetSnapshot: (next: string, expiresAt: number) => { + snapshot = next; + deadline = expiresAt; + }, + getWidgetSnapshot: () => snapshot, + end: vi.fn(), + }, + getDeadline: () => deadline, + resetNativeState: () => { + snapshot = null; + deadline = 0; + }, + }; +}); -vi.mock('expo', () => ({ requireOptionalNativeModule: () => null })); +vi.mock('expo', () => ({ requireOptionalNativeModule: () => mocks.native })); vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() }, Alert: { alert: vi.fn() }, @@ -108,6 +125,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(NOW); + mocks.resetNativeState(); store.clear(); secureStore.getItemAsync.mockReset().mockImplementation(async key => { await Promise.resolve(); @@ -199,6 +217,89 @@ describe.each([120, 250])('registered widget handler at %d dp', width => { expect(collectText(rendered.dark)).toEqual(expected); }); + it('re-reads native state when an old expiry task reaches newer work', async () => { + const old = snapshotFor(); + const handler = await registerAfterRestart(old); + const { androidSink } = await import('./android-sink'); + androidSink.publish(old); + const newer = buildGlanceableSnapshot({ + sessions: [{ status: 'busy' }], + userId: 'u2', + organizationId: null, + now: NOW + 60_000, + previousRevision: old.revision, + }); + mocks.native.setWidgetSnapshot(JSON.stringify(newer), Date.parse(newer.expiresAt)); + vi.setSystemTime(Date.parse(old.expiresAt)); + + const rendered = await runWidgetTask(handler, width); + const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents']; + expect(collectText(rendered.light)).toEqual(expected); + expect(collectText(rendered.dark)).toEqual(expected); + }); + + it.each([ + ['privacy', 'Agents hidden'], + ['signed_out', 'Sign in to see agents'], + ] as const)('reads a native %s blank instead of stale legacy storage', async (status, copy) => { + const old = snapshotFor(); + const handler = await registerAfterRestart(old); + mocks.native.setWidgetSnapshot(JSON.stringify(snapshotFor([], status)), 0); + vi.setSystemTime(Date.parse(old.expiresAt)); + + const rendered = await runWidgetTask(handler, width); + expect(collectText(rendered.light)).toEqual([copy]); + expect(collectText(rendered.dark)).toEqual([copy]); + expect(mocks.getDeadline()).toBe(0); + }); + + it.each(['happy', 'stale'] as const)( + 'renders native %s state after a JS reload without extending its expiry', + async status => { + const snapshot = snapshotFor(undefined, status); + const expiresAt = Date.parse(snapshot.expiresAt); + mocks.native.setWidgetSnapshot(JSON.stringify(snapshot), expiresAt); + vi.setSystemTime(NOW + 7_200_000); + const handler = await registerAfterRestart(null); + + const current = await runWidgetTask(handler, width); + expect(collectText(current.light)).toContain('1 Needs input'); + expect(collectText(current.dark)).toContain('1 Needs input'); + expect(mocks.getDeadline()).toBe(expiresAt); + + vi.setSystemTime(expiresAt); + const reloaded = await registerAfterRestart(null); + const expired = await runWidgetTask(reloaded, width); + expect(collectText(expired.light)).toEqual(['Status expired']); + expect(collectText(expired.dark)).toEqual(['Status expired']); + } + ); + + it('expires counts in an already-running handler without a JavaScript timer', async () => { + const snapshot = snapshotFor(); + const handler = await registerAfterRestart(snapshot); + await runWidgetTask(handler, width); + expect(mocks.getDeadline()).toBe(Date.parse(snapshot.expiresAt)); + vi.setSystemTime(Date.parse(snapshot.expiresAt)); + + const rendered = await runWidgetTask(handler, width); + expect(collectText(rendered.light)).toEqual(['Status expired']); + expect(collectText(rendered.dark)).toEqual(['Status expired']); + }); + + it.each([ + ['invalid JSON', '{'], + ['missing fields', JSON.stringify({ status: 'happy' })], + ['negative counts', JSON.stringify({ ...snapshotFor(), running: -1 })], + ])('rejects a native snapshot with %s', async (_reason, raw) => { + const handler = await registerAfterRestart(null); + mocks.native.setWidgetSnapshot(raw, 0); + + const rendered = await runWidgetTask(handler, width); + expect(collectText(rendered.light)).toEqual(['No work in progress']); + expect(collectText(rendered.dark)).toEqual(['No work in progress']); + }); + it('keeps live widget props published while restoration is pending', async () => { const stored = snapshotFor(); const handler = await registerAfterRestart(stored); diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts index 342343d60d..812fb43474 100644 --- a/apps/mobile/src/glanceable-android/register.ts +++ b/apps/mobile/src/glanceable-android/register.ts @@ -10,11 +10,8 @@ import { registerGlanceableSink } from '@/lib/glanceable/sink-registry'; import { renderActiveAgentsWidget } from './active-agents-widget'; import { androidSink, getCurrentWidgetProps, handleAppStateActive } from './android-sink'; -import { - buildAndroidWidgetProps, - buildExpiredWidgetProps, - buildGenericWidgetProps, -} from './widget-props'; +import { getStoredWidgetSnapshot, setWidgetSnapshot } from './live-update'; +import { buildCurrentWidgetProps, buildGenericWidgetProps } from './widget-props'; // Register the Android sink at import time. The main-app import of the local // live-update module loads this file, so the sink subscribes before any widget @@ -36,22 +33,22 @@ function translate(key: string): string { registerWidgetTaskHandler(async (task: WidgetTaskHandlerProps) => { const { widgetInfo, renderWidget } = task; - let props = getCurrentWidgetProps(); + // Re-read native storage even in a live process. An old alarm can already have + // queued this task when newer work or a privacy blank replaces its deadline. + const stored = getStoredWidgetSnapshot(); + let props = + stored === null ? getCurrentWidgetProps() : buildCurrentWidgetProps(stored, translate); if (props === null) { - // Headless restarts have no live widget props; restore the existing mirror. + // Migrate the existing mirror when this installation has no native snapshot yet. await restorePersistedGlanceable(); const snapshot = getLastGlanceableSnapshot(); - if (snapshot === null) { - props = buildGenericWidgetProps(translate); - } else if ( - snapshot.status !== 'privacy' && - snapshot.status !== 'signed_out' && - Date.parse(snapshot.expiresAt) <= Date.now() - ) { - props = buildExpiredWidgetProps(snapshot, translate); - } else { - props = buildAndroidWidgetProps(snapshot, {}, translate); + if (snapshot !== null && getCurrentWidgetProps() === null) { + setWidgetSnapshot(snapshot); } + props = + snapshot === null + ? buildGenericWidgetProps(translate) + : buildCurrentWidgetProps(snapshot, translate); // A live publish during restoration owns the widget. props = getCurrentWidgetProps() ?? props; } diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts index 72e9ec6d60..2fe145a0f6 100644 --- a/apps/mobile/src/glanceable-android/widget-props.test.ts +++ b/apps/mobile/src/glanceable-android/widget-props.test.ts @@ -2,11 +2,12 @@ import { buildGlanceableSnapshot, type GlanceableAgentsSnapshot, } from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildAndroidWidgetProps, buildCompactNotificationText, + buildCurrentWidgetProps, buildOngoingNotificationText, } from './widget-props'; @@ -124,6 +125,43 @@ describe('buildAndroidWidgetProps', () => { }); }); +describe('current widget deadline rendering', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(['happy', 'stale'] as const)('hides expired %s counts and the visible action', status => { + vi.useFakeTimers(); + vi.setSystemTime(NOW + 28_800_000); + const props = buildCurrentWidgetProps({ ...MIXED, status }, translate); + expect(props.statusLine).toBe('Status expired'); + expect(props.countLines).toEqual([]); + expect(props.accessibilityLabel).toBe('Status expired, Open agents'); + expect(props.showOpenAgents).toBe(false); + }); + + it.each([ + ['privacy', 'Agents hidden'], + ['signed_out', 'Sign in to see agents'], + ['empty', 'No work in progress'], + ['waiting', 'Waiting for agents'], + ] as const)('preserves %s copy beyond an old deadline', (status, expected) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW + 28_800_001); + const props = buildCurrentWidgetProps({ ...MIXED, status }, translate); + expect(props.statusLine).toBe(expected); + expect(props.accessibilityLabel).toBe(`${expected}, Open agents`); + expect(props.countLines).toEqual([]); + expect(props.showOpenAgents).toBe(false); + }); + + it('hides counts when the stored expiry is not a valid date', () => { + const props = buildCurrentWidgetProps({ ...MIXED, expiresAt: 'invalid' }, translate); + expect(props.statusLine).toBe('Status expired'); + expect(props.countLines).toEqual([]); + }); +}); + describe('buildOngoingNotificationText', () => { it('lists every ranked numeric count for happy work', () => { expect(buildOngoingNotificationText(MIXED, {}, translate)).toBe( diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts index 01edee2a1a..71434f679c 100644 --- a/apps/mobile/src/glanceable-android/widget-props.ts +++ b/apps/mobile/src/glanceable-android/widget-props.ts @@ -59,8 +59,23 @@ export function buildAndroidWidgetProps( }; } +/** Every redraw checks the data deadline, including a task queued by an older alarm. */ +export function buildCurrentWidgetProps( + snapshot: GlanceableAgentsSnapshot, + translate: (key: string) => string +): AndroidWidgetProps { + const expiresAt = Date.parse(snapshot.expiresAt); + if ( + (snapshot.status === 'happy' || snapshot.status === 'stale') && + (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) + ) { + return buildExpiredWidgetProps(snapshot, translate); + } + return buildAndroidWidgetProps(snapshot, {}, translate); +} + /** Zero-count expired props: the single future redraw hides counts at expiresAt. */ -export function buildExpiredWidgetProps( +function buildExpiredWidgetProps( snapshot: GlanceableAgentsSnapshot, translate: (key: string) => string ): AndroidWidgetProps {