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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { UserWebConnectionProvider } from '@/components/agents/user-web-connecti
import { KiloChatPresenceMount } from '@/components/kilo-chat/kilo-chat-presence-mount';
import { KiloChatProvider } from '@/components/kilo-chat/kilo-chat-provider';
import { SharePayloadNavigator } from '@/components/share/share-payload-navigator';
import { privacyScreenLayout } from '@/components/privacy-cover-overlay';
import { ActiveSessionsLiveSyncMount } from '@/lib/active-sessions-live-sync-mount';
import { attemptLogoutReconciliation } from '@/lib/auth/logout-reconciliation';
import {
Expand Down Expand Up @@ -120,7 +119,6 @@ export default function AppLayout() {
<KiloChatProvider>
<KiloChatPresenceMount>
<Stack
screenLayout={privacyScreenLayout}
screenOptions={{
contentStyle: { backgroundColor: colors.background },
headerShown: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { type Href, Stack, useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate';
import { privacyScreenLayout } from '@/components/privacy-cover-overlay';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useRouteForegroundRefresh } from '@/lib/hooks/use-route-foreground-refresh';
import {
Expand Down Expand Up @@ -68,7 +67,7 @@ export default function PrReviewNumberLayout() {
userId={userId}
draftEntityKey={draftEntityKey}
>
<Stack screenLayout={privacyScreenLayout} screenOptions={{ headerShown: false }}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="comment-composer" options={sheetOptions} />
<Stack.Screen name="review-submit" options={sheetOptions} />
<Stack.Screen name="merge" options={sheetOptions} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ describe('PrivacyCoverOverlay screen capture', () => {
const renderer = await render(['(app)', '(tabs)', '(0_home)']);
expect(captureMock.calls).toEqual([]);

await update(renderer, ['(app)', 'agent-chat', '[session-id]']);
await update(renderer, ['(app)', '(tabs)', '(3_profile)']);
await update(renderer, ['(app)', '(tabs)', '(0_home)']);
await update(renderer, ['(app)', 'pr-review', '[owner]']);
await update(renderer, ['(app)', '(tabs)', '(3_profile)', 'organization']);

// The last call must be `prevent`: the route is covered, so a trailing
// `allow` would leave FLAG_SECURE off on a sensitive screen.
Expand All @@ -96,7 +96,7 @@ describe('PrivacyCoverOverlay screen capture', () => {

it('never touches screen capture on iOS', async () => {
reactNativeMock.Platform.OS = 'ios';
const renderer = await render(['(app)', 'kilo-pass']);
const renderer = await render(['(app)', '(tabs)', '(3_profile)']);
expect(captureMock.calls).toEqual([]);

await act(async () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/components/privacy-cover-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export function PrivacyCoverOverlay({ segments }: Readonly<PrivacyCoverOverlayPr
* other view mounted in the root tree — draws behind it. This one lives inside
* the presented screen's own tree, so it blanks that screen.
*
* Pass it as `screenLayout` on every Stack that declares a sheet route. It
* costs nothing on an uncovered route, so it is set per navigator rather than
* per screen, and a sheet route added later is covered without a code change.
* Pass it as `screenLayout` on every Stack inside the profile tab that
* declares a sheet route. It costs nothing on an uncovered route, so it is set
* per navigator rather than per screen.
*/
export function privacyScreenLayout({
children,
Expand Down
50 changes: 12 additions & 38 deletions apps/mobile/src/lib/privacy-cover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,9 @@ import { describe, expect, it } from 'vitest';
import { isPrivacyCoverRoute } from './privacy-cover';

describe('isPrivacyCoverRoute', () => {
it('covers the login route', () => {
expect(isPrivacyCoverRoute(['login'])).toBe(true);
expect(isPrivacyCoverRoute(['(auth)', 'login'])).toBe(true);
});

it('covers language pickers above login and preferences', () => {
expect(isPrivacyCoverRoute(['(auth)', 'language-picker'])).toBe(true);
expect(isPrivacyCoverRoute(['(app)', 'language-picker'])).toBe(true);
});

it('covers the agent-chat route', () => {
expect(isPrivacyCoverRoute(['agent-chat'])).toBe(true);
expect(isPrivacyCoverRoute(['agent-chat', '[session-id]'])).toBe(true);
});

it('covers the pr-review route', () => {
expect(isPrivacyCoverRoute(['pr-review'])).toBe(true);
expect(isPrivacyCoverRoute(['pr-review', '[owner]', '[repo]', '[number]'])).toBe(true);
});

it('covers the kilo-pass route', () => {
expect(isPrivacyCoverRoute(['kilo-pass'])).toBe(true);
});

it('covers the device-sessions route', () => {
expect(isPrivacyCoverRoute(['device-sessions'])).toBe(true);
it('leaves language pickers above login and preferences uncovered', () => {
expect(isPrivacyCoverRoute(['(auth)', 'language-picker'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', 'language-picker'])).toBe(false);
});

it('covers the profile tab group', () => {
Expand All @@ -38,24 +15,21 @@ describe('isPrivacyCoverRoute', () => {
).toBe(true);
});

it('leaves the home tab group uncovered', () => {
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(0_home)'])).toBe(false);
});

it('leaves unrelated routes uncovered', () => {
it('leaves every other surface uncovered', () => {
expect(isPrivacyCoverRoute([])).toBe(false);
expect(isPrivacyCoverRoute(['(app)'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(2_agents)'])).toBe(false);
});

it('excludes kiloclaw even under a profile-like path', () => {
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(0_home)'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(1_kiloclaw)'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', 'kiloclaw', '[instance-id]', 'dashboard'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(3_profile)', 'kiloclaw'])).toBe(false);
expect(isPrivacyCoverRoute(['(app)', '(tabs)', '(2_agents)'])).toBe(false);
expect(isPrivacyCoverRoute(['(auth)', 'login'])).toBe(false);
expect(isPrivacyCoverRoute(['agent-chat', '[session-id]'])).toBe(false);
expect(isPrivacyCoverRoute(['pr-review', '[owner]', '[repo]', '[number]'])).toBe(false);
expect(isPrivacyCoverRoute(['kilo-pass'])).toBe(false);
expect(isPrivacyCoverRoute(['device-sessions'])).toBe(false);
expect(isPrivacyCoverRoute(['share-gate'])).toBe(false);
});

it('matches tokens, not slash-prefixed paths', () => {
expect(isPrivacyCoverRoute(['/login'])).toBe(false);
expect(isPrivacyCoverRoute(['/(3_profile)'])).toBe(false);
});
});
35 changes: 7 additions & 28 deletions apps/mobile/src/lib/privacy-cover.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,17 @@
/**
* Whether the active route must be hidden from OS snapshots (Recents / app
* switcher) and from screen capture. Home and KiloClaw surfaces stay visible;
* every other sensitive surface (profile, login, agent sessions, PR review,
* Kilo Pass, device sessions, the share gate) is covered.
* switcher) and from screen capture. Only the profile tab qualifies: it is the
* one surface that shows the credit balance and account identity. Everything
* else — home, agents, KiloClaw, PR review, login — is not worth the cost of a
* blank Recents card.
*
* Consumes the raw tokens from expo-router's `useSegments()`: it reports
* `login`, not `/login`, so these are token matches, not slash-prefixed paths.
* `(3_profile)`, not `/(3_profile)`, so this is a token match, not a
* slash-prefixed path match.
*/

const KILOCLAW_MARKER = 'kiloclaw';
const HOME_SEGMENT = '(0_home)';
const PROFILE_SEGMENT = '(3_profile)';
const COVERED_TOKENS: ReadonlySet<string> = new Set([
'login',
'language-picker',
'agent-chat',
'pr-review',
'kilo-pass',
'device-sessions',
// Sibling formSheet of `(app)`: previews inbound share text, images and
// filenames, and at its half detent leaves the route under it on screen.
'share-gate',
]);

export function isPrivacyCoverRoute(segments: readonly string[]): boolean {
// KiloClaw owns its own snapshots and must never be covered, even when it
// sits under a profile-like path.
if (segments.some(segment => segment.includes(KILOCLAW_MARKER))) {
return false;
}
if (segments.includes(HOME_SEGMENT)) {
return false;
}
return (
segments.includes(PROFILE_SEGMENT) || segments.some(segment => COVERED_TOKENS.has(segment))
);
return segments.includes(PROFILE_SEGMENT);
}