feat(mobile): Android push, external auth, and force-update hardening - #5369
feat(mobile): Android push, external auth, and force-update hardening#5369iscekic wants to merge 27 commits into
Conversation
Register the signed-in user's device token on authenticated mount, foreground, and Expo token rotation, mirroring logout reconciliation (single-flight, 60s spacing, epoch-fenced). Await in-flight logout reconciliation first so a same-user re-login cannot delete the just-registered row.
Create five Android notification channels before permission/token, derive a channel id and generic fallback from PushData.type, and add a server-resolved message-preview preference (generic/full) with a notifications-screen switch. Gate channelId on a nullable app_version so old Android clients that never created channels still receive push on the default channel.
Add preservation tests proving a single category toggle cannot delete unrelated subscriptions, token registration never touches preferences, and each producer gate reads only its own category and fails closed on a thrown preference read.
Mirror the pending deep-link slot to SecureStore, restore it before the gate effect, and consume it exactly once after the gates clear. Serialize persist/delete writes so a later clear wins, and clear the slot on sign-out.
…nsactions
- Route security-agent and provider-connect through the shared
openAuthorizationAndWaitForReturn + useExternalAuthReturn, so Android
refetches on foreground and iOS on sheet close.
- Return { cleanup, pollNow } from startDeviceAuthPoll and poll once per
foreground while pending, bounded to one request per second.
- Persist the pending device-auth transaction in SecureStore with a 5-minute
TTL and restore it on mount (epoch-fenced against a live start).
- Show "Continuing your sign-in." on a resumed flow.
- Defer attachment upload to Send: chips stay pending, uploadPending uploads them and returns the wire/submission payloads plus full R2 keys. - Strip EXIF/GPS metadata from images on add; delete local files on remove. - Recover a pending Android image-picker result after Activity recreation, matched against a persisted launch context (account, surface, session, TTL). - Add the cloud_agent_attachment_uploads ledger, markAttachmentsSent, and a cron that reaps unconsumed uploads past 24h; mark consumed server-side in prepareSession/sendMessage for old clients.
Rewrite the FIFO SecureStore write chain with an async IIFE instead of then/catch, and suppress prefer-await-to-then on the empty-chain sentinel.
… fallback - Delete ledger rows conditionally on still-unconsumed before deleting the R2 object, so a send that marks consumed between select and delete is never reaped. - Mark the same payload the Worker received (attachments ?? images) consumed. - Toast when a send is blocked by an in-flight upload; catch a picker launch context write failure so it never blocks the picker. - Assert the old-client consume path in the router tests.
…e failure Add tests proving the conditional delete keeps a consumed row's object, the composer toasts on an in-flight upload, and the picker still launches when the launch context write rejects.
The native token and OTP routes return { error: 'SSO_ERROR', ssoOrganizationId }
but the client dropped the organization id and showed a dead-end toast. Parse
the organization id, expose an ssoRecovery state on useNativeAuth, and render a
Continue-with-SSO block that opens the web SSO page with the email. The
organization id stays in state and a Sentry breadcrumb, never in a URL.
Switch the two dirty-form guards from the beforeRemove listener to usePreventRemove so the predictive Back gesture can be blocked before it commits. Re-export the vendored hook through one deep import, flip predictiveBackGestureEnabled on, and pin the re-export with a vitest test.
The sign-in screen now states sign in or create an account, shows Terms and Privacy before the first code is sent, and the native token route returns created so a brand-new email announces the created account.
…nsent Dictation starts with on-device recognition when supported. When it is not, a per-user disclosure requires consent before audio is sent to Apple or Google, with a Voice transcription control in consent details.
Lock the On device / Online, allowed / Online, not allowed labels and the disclosure title and body, and assert the on-device path raises no prompt.
Add a fail-closed tRPC middleware that refuses mobile clients below the configured minimum version. Mobile requests now carry x-kilo-client, x-kilo-app-platform, and x-kilo-app-version metadata headers merged at the call sites, keeping auth-header.ts pure. isVersionBelow moves to packages/app-shared so web and mobile share one implementation.
…onnect Rewrite useForceUpdate around a re-runnable check that runs on mount, foreground, and reconnect, bounded to one check per 30s and single-flight. A server-side app_update_required refusal flips an observable signal that routes to /force-update immediately; a successful up-to-date check clears it so lowering the minimum clears the block.
…heck resolveForceUpdateState now returns a three-way state so a fail-open result (non-ok, missing version, malformed body) no longer clears the server signal. Add retryable-unhappy coverage.
- Add the drizzle delete-rule suppression to the orphan-attachment cron test. - Remove two unused jest mock declarations in the cloud-agent router test. - Add the missing PICKER_LAUNCH_CONTEXT_KEY to the storage-keys mock in auth-context.test.tsx, restoring the 19 sign-out teardown tests. - Reformat five files with the repository format script.
…cates - Add the missing markCloudAgentAttachmentUploadsConsumed mock to the org router test and assert consume for prepareSession and sendMessage, including the legacy images fallback. - Pin that markAttachmentsSent scopes its update to the caller's user_id. - Assert the orphan-attachment cron selects only unconsumed rows past the TTL.
- A failed markAttachmentsSent after a successful prepareSession no longer reports the create as failed or invites a duplicate session. - Push-token reconciliation spacing is per-user, so a new sign-in within 60 s of a prior user's attempt still registers the token (QB-04).
- Roll back the optimistic flip and toast a retryable error when the voice consent write fails. - Show a state-specific message instead of a dead switch when no user is signed in, and record why a non-retryable state is impossible.
- Show a loading placeholder while getMe resolves instead of sign-in copy. - Show a retry CTA when the user id fails to load, so a signed-in user is never told to sign in.
…d-6619 # Conflicts: # apps/web/src/lib/user/index.ts
Three landed slices pulled native modules or new hooks into existing pure tests, whose mocks were not updated: - chat-composer.test.ts now mocks upload-task (expo-file-system/legacy) and the picker-recovery hook, and stubs uploadPending. - pr-review-connect-gate-view.test.ts mocks useCallback in the react mock. - credentials.test.ts adds captureException to the sentry mock.
The notifications and deep-link-launch slices import @sentry/react-native, whose barrel export pulls react-native into the pure-test module graph. Mock it here so the extensionless promise/setimmediate import inside react-native/Libraries/Promise.js is never resolved.
| void attemptLogoutReconciliation(userId); | ||
| await awaitLogoutReconciliationSettled(); | ||
|
|
||
| if (attemptInFlight) { |
There was a problem hiding this comment.
WARNING: In-flight reconciliation for user A can keep the device token on A after a switch to B
attemptInFlight drops the new user's attempt with no retry, and a completed registerPushToken is never rolled back when the auth epoch moves. A foreground A→B switch while A's lookup/mutate is in flight:
- B's
PushRegistrationMountcalls this and gets{ kind: 'in-flight' }. - A's already-dispatched mutate can still upsert the device token to A.
- The post-mutate epoch check only skips cache invalidation — it does not unregister or re-home the token.
- B is not retried unless the user backgrounds the app; the 60s spacing/
already-registeredshort-circuit can then leave the token on A.
The previous account can keep receiving this device's notifications after the switch.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| return result.uri; | ||
| } catch (error) { | ||
| Sentry.captureException(error); | ||
| return uri; |
There was a problem hiding this comment.
WARNING: Strip failure still uploads the original image with EXIF/GPS
On any manipulateAsync failure this returns the original URI. addCandidates sets metadataStripFailed when the URI is unchanged, but nothing reads that flag — no warning, no send block, no retry. Send still PUTs localUri, so location, EXIF, and maker notes reach R2 whenever the manipulator throws (unsupported decode, OOM, HEIC). The privacy claim is fail-open.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // Mismatch (wrong account/surface/session) or expired: discard the | ||
| // pending result so a later launch cannot receive a stale result, then | ||
| // clear the context. | ||
| await discardAndroidPendingPickerResult(); |
There was a problem hiding this comment.
WARNING: A non-matching mounted composer can consume the pending Android picker result
Recovery runs on every mounted composer on mount and AppState active, not only the focused screen. Session screens stay mounted under a pushed session (ChatComposer in each), and agent-chat/new mounts its own hook. After Activity recreation every instance reads the same launch context; a mismatch calls discardAndroidPendingPickerResult() → getPendingResultAsync(), which consumes the native pending result, then clears the context. The matching composer then sees nothing. inFlightRef is per hook instance, so this is a real concurrent race and the photo is never attached.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // process cannot navigate to the previous account's destination. The | ||
| // in-memory clear is synchronous; the persisted delete chains behind | ||
| // any in-flight persist. | ||
| clearPendingDeepLink(); |
There was a problem hiding this comment.
WARNING: Pending deep-link is cleared too late and is not account-bound
clearPendingDeepLink() runs only after await runLogoutCleanup(), which can take many seconds. The durable record is { href, source, storedAt } with no user id. If the process dies during that await — or the fire-and-forget SecureStore delete never lands — the next cold start restores the slot and the next signed-in account navigates to the previous account's notification or universal-link destination.
Clear the slot at the start of sign-out (with the other sync fences) and persist a user id so restore cannot apply another account's destination.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 4 Issues Found | Recommendation: Address before merge Executive SummaryPush-token ownership and EXIF stripping can fail open after an account switch or manipulator error, leaking notifications or photo metadata. Overview
Issue Details (click to expand)WARNING
Files Reviewed (142 files)
Fix these issues in Kilo Cloud Reviewed by grok-4.6 · Input: 323.9K · Output: 52.6K · Cached: 1.2M Review guidance: REVIEW.md from base branch |
Summary
Android push now targets named notification channels, and a per-user preview setting chooses generic or full lock-screen text. The dispatch Durable Object resolves the channel and the preview copy from the payload and the stored preference, and it attaches a channelId only to clients that registered with an app version, so older Android clients keep the default channel. The preview default is generic, so lock screens never leak content until the user opts in.
Files
packages/notifications/src/push-presentation.ts— new; the channel list, the payload-to-channel map, and content-free generic copy.packages/notifications/src/index.ts— exports the new push-presentation module.packages/notifications/package.json— adds a test script and the vitest dev dependency.packages/notifications/vitest.config.ts— new; node-environment vitest config for the package.services/notifications/src/dos/NotificationChannelDO.ts— resolves channelId and preview mode per push; gates channelId on a non-null app version; logs content-free sink payloads.apps/mobile/src/lib/notifications.ts— creates the Android channels before permission and token; single-flight channel creation.apps/mobile/src/components/notifications-screen.tsx— renders the "Message previews" toggle and the per-category toggles.apps/mobile/src/lib/hooks/agent-push-preference.ts— adds the notificationPreviews field to the preference shape.apps/web/src/routers/user-router.ts— accepts appVersion on token registration and reads/writes notificationPreviews.packages/db/src/schema.ts— adds the nullable app_version column and the notification_previews column.The signed-in account now owns the device push token. A single-flight, 60-second-spaced reconciliation runs on login, on each foreground return, and on token rotation, and it is epoch-fenced so a sign-out or sign-in mid-attempt drops the result instead of registering for a stale user. It orders against logout cleanup so a same-user re-login cannot delete the row the reconciliation just wrote.
Files
apps/mobile/src/lib/auth/push-registration-reconciliation.ts— new; the single-flight, epoch-fenced reconciliation and the token-rotation subscription.apps/mobile/src/lib/auth/logout-reconciliation.ts— adds a settle-await helper so registration cannot race an in-flight unregister.apps/mobile/src/app/(app)/_layout.tsx— mounts the reconciliation trigger on login, foreground, and rotation.A deep-link or notification destination now survives process death. The pending slot is mirrored to secure storage with a 24-hour TTL, restored on cold start, and consumed once by the root layout after the consent, force-update, and login gates clear. Sign-out clears the slot so a later account cannot navigate to the previous account's destination.
Files
apps/mobile/src/lib/deep-link-launch.ts— durable mirror with a write chain, restore with TTL, an observable slot, and synchronous launch capture.apps/mobile/src/lib/storage-keys.ts— adds the pending-deep-link, picker-launch-context, and voice-network-consent keys.apps/mobile/src/app/_layout.tsx— restores and consumes the slot; routes the force-update gate and the share gate.apps/mobile/src/lib/auth/auth-context.tsx— clears the pending destination and the picker context on sign-out.apps/mobile/src/lib/pending-share-navigation.ts— shares the shell-ready guard with the deep-link consumer.Every external-auth flow now returns correctly on Android. Android opens a plain browser and refetches on foreground return, while iOS keeps the native auth session that resolves on sheet close; one shared helper picks the launcher and the refetch trigger per platform. A pending device-auth transaction is persisted so it resumes or expires after process death, reusing the original start clock.
Files
apps/mobile/src/lib/auth/pending-external-auth.ts— new; persists the pending device-auth transaction with a 5-minute TTL.apps/mobile/src/lib/auth/device-auth-poll.ts— polls once on foreground and reuses the original start clock on resume.apps/mobile/src/lib/auth/device-auth-state.ts— adds the resumed flag to the pending state.apps/mobile/src/lib/auth/use-device-auth.ts— restores the pending transaction and routes signup and SSO through the web sign-in page.apps/mobile/src/lib/external-auth/use-external-auth-return.ts— new; the launch sentinel plus the Android foreground listener.apps/mobile/src/lib/pr-review/connect-gate-platform.ts— maps platform to the launcher and refetch trigger.apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx— uses the shared return helper for its connect flow.apps/mobile/src/components/code-reviewer/provider-connect-card.tsx— uses the shared return helper for GitHub and GitLab connect.apps/mobile/src/components/security-agent/security-agent-setup.tsx— uses the shared return helper for its setup flow.Attachments are stripped of metadata, uploaded at send time, and recovered after Android process death. Images are re-encoded to drop EXIF, GPS, and maker notes before upload, uploads are deferred until send, and a pending picker result is matched to the launching account and surface after an Activity recreation. A local file the app produced is deleted on remove or reset.
Files
apps/mobile/src/lib/agent-attachments/agent-attachment-types.ts— adds the submission payload and the retryable/terminal failure classifier.apps/mobile/src/lib/agent-attachments/pending-picker-result.ts— new; consumes or discards a pending Android picker result.apps/mobile/src/lib/agent-attachments/picker-launch-context.ts— new; persists the launching account, surface, and session.apps/mobile/src/lib/agent-attachments/strip-image-metadata.ts— new; re-encodes images to drop metadata.apps/mobile/src/lib/agent-attachments/upload-task.ts— adds markAttachmentsSent and the local-size and filename helpers.apps/mobile/src/lib/agent-attachments/use-agent-attachment-upload.ts— defers upload to send and guards stale async outcomes.apps/mobile/src/lib/agent-attachments/use-android-pending-picker-recovery.ts— new; matches and recovers a pending result after recreation.apps/mobile/src/components/agents/attachment-picker.ts— records the launch context before camera and library launches.apps/mobile/src/components/agents/chat-composer.tsx— uploads pending chips on send and marks them sent.apps/mobile/src/components/agents/chat-composer-input-state.ts— counts sendable attachments for the send gate.apps/mobile/src/components/agents/use-new-session-creator.ts— uploads pending chips and marks them sent on create.apps/mobile/src/components/agents/session-detail-content.tsx— threads the session id into the composer for the picker context.apps/mobile/src/app/(app)/agent-chat/new.tsx— wires picker recovery and the discard guard.apps/mobile/src/lib/new-session-submit.ts— stops an in-flight upload from blocking the start button.Cloud Agent attachment uploads now have a consume-exactly-once ledger and a TTL reaper. A presign writes a ledger row, a sent message marks it consumed, and a daily cron deletes unconsumed rows past 24 hours along with their R2 objects. Old clients that never mark attachments sent are covered because the send mutations mark the rows from the wire payload they already carry.
Files
apps/web/src/lib/r2/cloud-agent-attachments.ts— writes the ledger row on presign and marks rows consumed from the wire payload.apps/web/src/routers/cloud-agent-next-router.ts— adds markAttachmentsSent and marks rows consumed on prepare and send.apps/web/src/routers/cloud-agent-next-schemas.ts— adds the relaxed filename, extension, and mark-sent schemas.apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts— mirrors the personal attachment procedures for orgs.apps/web/src/app/api/cron/cleanup-orphan-agent-attachments/route.ts— new; the TTL reaper that deletes rows then objects.apps/web/src/components/cloud-agent-next/ChatInput.tsx— marks attachments sent after an accepted send.apps/web/src/lib/user/index.ts— soft delete nulls the ledger user and consumed columns.apps/web/vercel.json— schedules the reaper cron daily.Voice transcription prefers on-device recognition and requires consent before any network fallback. When the device cannot transcribe offline, the user must approve online transcription through a disclosure, and that per-user decision is stored and surfaced in consent details. The controller threads an on-device flag through start.
Files
apps/mobile/src/lib/voice-input/voice-input-recognition-mode.ts— new; resolves on-device, network, or blocked from support and consent.apps/mobile/src/lib/voice-input/voice-network-consent.ts— new; per-user network-fallback consent store.apps/mobile/src/lib/voice-input/use-voice-input-actions.ts— raises the disclosure and branches on the resolved mode.apps/mobile/src/lib/voice-input/use-voice-input.ts— threads the user id into the actions.apps/mobile/src/lib/voice-input/voice-input-controller.ts— adds the on-device start flag and the support probe.apps/mobile/src/lib/voice-input/native-voice-input.ts— binds the native on-device support and start flag.apps/mobile/src/components/consent/consent-details.tsx— adds the "Voice transcription" disclosure and its control.The app rechecks the minimum version on foreground and on reconnect, and the server now enforces it fail-closed. The mobile client sends platform and version headers on every request and fails open on its own check, while a tRPC middleware refuses a mobile client whose version is below the configured minimum. A 5-minute cached read with a stale-cache fallback bounds the database cost.
Files
apps/mobile/src/lib/force-update-policy.ts— new; resolves the check to update-required, up-to-date, or unknown.apps/mobile/src/lib/force-update-signal.ts— new; module signal raised from tRPC update-required errors.apps/mobile/src/lib/hooks/use-force-update.ts— rechecks on foreground and on reconnect.apps/mobile/src/lib/client-metadata.ts— new; builds the platform and version headers.apps/mobile/src/lib/trpc.ts— attaches the metadata headers to every request.apps/mobile/src/lib/query-client.ts— reports tRPC errors to the force-update signal.apps/web/src/lib/trpc/min-version.ts— new; the fail-closed enforcement and the cached minimum read.apps/web/src/lib/trpc/init.ts— adds the minimum-version middleware to the base procedure.packages/app-shared/src/app-version.ts— new; the dotted-version comparison.packages/app-shared/package.json— exports the app-version module.Android predictive Back is enabled and guarded. Leaving a screen with unsaved changes or a non-empty draft now intercepts the hardware back, the header back, and the iOS swipe gesture through one shared guard, and replays the navigation only after the user resolves the confirmation.
Files
apps/mobile/src/lib/navigation/prevent-remove.ts— new; re-exports the deep usePreventRemove import.apps/mobile/src/lib/hooks/use-settings-back-guard.ts— the shared dirty-screen back guard with a save/discard/keep alert.apps/mobile/src/app/(app)/agent-chat/use-new-session-discard-guard.ts— the new-session discard confirmation with a bypass.apps/mobile/app.config.ts— enables predictive Back on Android.Native sign-in now recovers SSO and speaks honestly. When an organization requires SSO, the app surfaces a Continue with SSO action that routes through the web sign-in page with the email, and the sign-in screen says sign in or create an account with Terms and Privacy first and a created-account outcome. The native token route reorders admission verification before user settlement and binds the attested key to the settled user.
Files
apps/mobile/src/lib/auth/use-sso-recovery.ts— new; holds the SSO email and reports the organization id as a Sentry tag only.apps/mobile/src/lib/auth/use-native-auth.ts— surfaces SSO recovery and the created-account toast.apps/mobile/src/lib/auth/auth-error-messages.ts— new; the error-code message map.apps/mobile/src/lib/auth/auth-fetch.ts— returns the SSO organization id and attaches metadata headers.apps/mobile/src/lib/auth/native-auth-contract.ts— parses the SSO organization id and the complete token pair.apps/mobile/src/lib/auth/resolve-admission.ts— new; normalizes admission failures to one retryable message.apps/mobile/src/components/login/idle-auth.tsx— the honest sign-in copy, Terms and Privacy links, and the SSO recovery block.apps/mobile/src/components/login-screen.tsx— shows the resumed sign-in state.apps/web/src/app/api/auth/native/token/route.ts— reorders admission verification before settlement and binds the attested key.Build and test configuration is updated for the new surfaces. The mobile app adds the image-manipulator dependency and the Terms and Privacy URLs, and the test configuration moves the login mounted test out of the pure suite and adds the navigation suite. The notifications package gains a test script.
Files
apps/mobile/package.json— adds the expo-image-manipulator dependency.apps/mobile/vitest.pure.config.ts— adds the navigation suite and drops the login mounted suite from the pure project.apps/mobile/src/lib/config.ts— adds the Terms and Privacy URLs.apps/mobile/src/components/consent/consent-card.tsx— imports the shared privacy URL.Tests: 47 test files added or updated across the mobile app, the web app, and the shared packages.
Generated: 1 lockfile and 7 database-migration artifacts updated.
Visual Changes
N/A
Human Steps
None known.
Notes
None.
Verification
Reviewer Notes
Review the schema migration and the push Durable Object first, then the mobile reconciliation and attachment flows. The three reproduced baselines fail to reproduce on the fixed build, which is the acceptance criterion.