diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml
index 94888be8e2..579c7ef5d6 100644
--- a/.github/workflows/kilo-app-release.yml
+++ b/.github/workflows/kilo-app-release.yml
@@ -52,8 +52,64 @@ jobs:
validate:
uses: ./.github/workflows/kilo-app-ci.yml
+ preflight:
+ needs: [check-changes]
+ if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main'
+ runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }}
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
+ with:
+ lfs: true
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
+
+ - name: Setup Node
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check EXPO_TOKEN
+ run: |
+ if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
+ echo "::error::EXPO_TOKEN secret is required for the release preflight"
+ exit 1
+ fi
+ echo "EXPO_TOKEN is present"
+
+ - name: Verify EAS production environment
+ working-directory: apps/mobile
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: |
+ ENV_OUTPUT=$(pnpx eas-cli@21.8.0 env:list --environment production --format long)
+ MISSING=""
+ for NAME in API_BASE_URL WEB_BASE_URL CLOUD_AGENT_WS_URL SESSION_INGEST_WS_URL APPSFLYER_DEV_KEY APPSFLYER_APP_ID KILO_CHAT_URL EVENT_SERVICE_URL NOTIFICATIONS_URL POSTHOG_API_KEY SENTRY_AUTH_TOKEN EXPO_PUBLIC_SENTRY_ENVIRONMENT; do
+ if ! printf '%s\n' "$ENV_OUTPUT" | grep -qE "^Name[[:space:]]+${NAME}[[:space:]]*$"; then
+ MISSING="$MISSING $NAME"
+ fi
+ done
+ if [ -n "$MISSING" ]; then
+ echo "::error::Missing EAS production environment variables:$MISSING"
+ exit 1
+ fi
+ echo "All required EAS production environment variables are present"
+
+ - name: Assert production config contract
+ working-directory: apps/mobile
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: EAS_BUILD_PROFILE=production pnpx eas-cli@21.8.0 env:exec production --non-interactive 'pnpm assert:config'
+
build-and-submit:
- needs: [check-changes, validate]
+ needs: [check-changes, validate, preflight]
if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main'
runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }}
timeout-minutes: 60
@@ -74,11 +130,59 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Build and submit
+ - name: Build iOS and Android
+ working-directory: apps/mobile
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: |
+ mkdir -p artifacts
+ pnpx eas-cli@21.8.0 build --profile production --platform all --non-interactive --json --wait > build.json
+ node ../../scripts/inspect-mobile-artifacts.mjs --select build.json > urls.txt
+ echo "IOS_URL=$(sed -n '1p' urls.txt)" >> "$GITHUB_ENV"
+ echo "ANDROID_URL=$(sed -n '2p' urls.txt)" >> "$GITHUB_ENV"
+
+ - name: Download artifacts
+ working-directory: apps/mobile
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: |
+ download() {
+ url="$1"
+ out="$2"
+ label="$3"
+ if curl -fL -H "Authorization: Bearer $EXPO_TOKEN" -o "$out" "$url"; then
+ echo "$label: downloaded with Authorization header"
+ elif curl -fL -o "$out" "$url"; then
+ echo "$label: downloaded without Authorization header (pre-signed URL rejected the header)"
+ else
+ echo "::error::$label download failed"
+ return 1
+ fi
+ }
+ download "$IOS_URL" artifacts/app.ipa "iOS"
+ download "$ANDROID_URL" artifacts/app.aab "Android"
+
+ - name: Setup Java
+ uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Inspect artifacts
+ working-directory: apps/mobile
+ run: node ../../scripts/inspect-mobile-artifacts.mjs artifacts/app.ipa artifacts/app.aab build.json
+
+ - name: Submit iOS
+ working-directory: apps/mobile
+ env:
+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
+ run: pnpx eas-cli@21.8.0 submit --profile production --platform ios --non-interactive --path artifacts/app.ipa
+
+ - name: Submit Android
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
- run: pnpx eas-cli@latest build --profile production --platform all --auto-submit --non-interactive
+ run: pnpx eas-cli@21.8.0 submit --profile production --platform android --non-interactive --path artifacts/app.aab
- name: Tag release
run: |
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 5b3b7bdc90..fc34f66e3e 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -2,17 +2,50 @@ import type { ExpoConfig } from 'expo/config';
import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys';
import { SENTRY_NATIVE_OPTIONS } from './src/lib/sentry-dsn';
import { UNIVERSAL_LINK_PATH_PATTERNS } from './src/lib/universal-link-paths';
+import {
+ assertProductionHost,
+ assertUrlScheme,
+ PRODUCTION_HOSTS,
+ URL_SCHEMES,
+} from './src/lib/url-contract';
+const isProductionBuild = process.env.EAS_BUILD_PROFILE === 'production';
+
+// Required env is fatal by build intent: a production build must never ship
+// with a missing value, so throw under EAS_BUILD_PROFILE === 'production'.
+// Otherwise keep the old behavior: warn under GITHUB_ACTIONS, throw locally.
const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]);
if (missing.length > 0) {
const message = `Missing required environment variables: ${missing.join(', ')}`;
- if (process.env.GITHUB_ACTIONS) {
+ if (isProductionBuild) {
+ throw new Error(message);
+ } else if (process.env.GITHUB_ACTIONS) {
console.warn(`⚠️ ${message}`);
} else {
throw new Error(message);
}
}
+// URL contract: every URL value must use its allowed scheme. Non-production
+// builds additionally permit http:/ws: for local development; production
+// builds also assert the host against the production allowlist.
+for (const [key, schemes] of Object.entries(URL_SCHEMES)) {
+ const value = process.env[ENV_KEYS[key as keyof typeof ENV_KEYS]];
+ if (!value) continue;
+ assertUrlScheme(key, value, schemes, { allowInsecure: !isProductionBuild });
+ if (isProductionBuild) {
+ assertProductionHost(key, value, PRODUCTION_HOSTS);
+ }
+}
+
+// Source-map gate: an unauthenticated production artifact must never reach the
+// stores with silently missing symbolication.
+if (isProductionBuild && !process.env.SENTRY_AUTH_TOKEN) {
+ throw new Error(
+ 'Missing SENTRY_AUTH_TOKEN: production builds require an authenticated Sentry source-map upload.'
+ );
+}
+
// Google OAuth client IDs are public identifiers (committed .env, all EAS
// environments). The conditional below tolerates their absence so the app still builds when a
// checkout lacks them; the native Google button hides itself when undefined.
@@ -222,6 +255,7 @@ const config: ExpoConfig = {
Object.entries(OPTIONAL_ENV_KEYS).map(([key, env]) => [key, process.env[env]])
),
router: {},
+ isProductionBuild,
eas: {
projectId: '2cf05e39-90b5-48a5-a8a5-e0b3423cf3f4',
},
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index b7f5d4b6dd..ba320184b1 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -1,6 +1,6 @@
{
"cli": {
- "version": ">= 21.1.0",
+ "version": "21.8.0",
"appVersionSource": "remote"
},
"build": {
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 9a757588b0..03237ef163 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -6,12 +6,13 @@
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios",
- "build": "pnpx eas-cli@latest build --profile development",
- "build:ios": "pnpx eas-cli@latest build -p ios --profile development",
- "build:android": "pnpx eas-cli@latest build -p android --profile development",
- "release:internal": "pnpx eas-cli@latest build --profile production --auto-submit",
- "release:internal:ios": "pnpx eas-cli@latest build -p ios --profile production --auto-submit",
- "release:internal:android": "pnpx eas-cli@latest build -p android --profile production --auto-submit",
+ "build": "pnpx eas-cli@21.8.0 build --profile development",
+ "build:ios": "pnpx eas-cli@21.8.0 build -p ios --profile development",
+ "build:android": "pnpx eas-cli@21.8.0 build -p android --profile development",
+ "release:internal": "pnpx eas-cli@21.8.0 build --profile production --auto-submit",
+ "release:internal:ios": "pnpx eas-cli@21.8.0 build -p ios --profile production --auto-submit",
+ "release:internal:android": "pnpx eas-cli@21.8.0 build -p android --profile production --auto-submit",
+ "assert:config": "node scripts/assert-expo-config.mjs",
"typecheck": "tsgo --noEmit",
"lint": "pnpm -w exec oxlint --config apps/mobile/.oxlintrc.json apps/mobile/src",
"format": "oxfmt src",
diff --git a/apps/mobile/scripts/assert-expo-config.mjs b/apps/mobile/scripts/assert-expo-config.mjs
new file mode 100644
index 0000000000..6f68e09021
--- /dev/null
+++ b/apps/mobile/scripts/assert-expo-config.mjs
@@ -0,0 +1,96 @@
+import { execFileSync } from 'node:child_process';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { ENV_KEYS } from '../src/lib/env-keys.js';
+
+// Contract values mirrored from app.config.ts (bundle id, package, scheme,
+// associated domain, blocked permissions, and Sentry plugin). ENV_KEYS is
+// imported live from src/lib/env-keys.js. The script runs the full evaluated
+// config, so these must match the resolved build-time output, not the raw
+// app.config.ts source.
+const BUNDLE_IDENTIFIER = 'com.kilocode.kiloapp';
+const ANDROID_PACKAGE = 'com.kilocode.kiloapp';
+const SCHEME = 'kiloapp';
+const ASSOCIATED_DOMAIN = 'applinks:app.kilo.ai';
+const BLOCKED_PERMISSIONS = [
+ 'android.permission.READ_MEDIA_IMAGES',
+ 'android.permission.READ_MEDIA_VIDEO',
+ 'android.permission.READ_MEDIA_AUDIO',
+];
+const SENTRY_PLUGIN = '@sentry/react-native/expo';
+
+const mobileDir = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+let raw;
+try {
+ raw = execFileSync('npx', ['expo', 'config', '--json'], {
+ cwd: mobileDir,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'inherit'],
+ });
+} catch (error) {
+ console.error(`Failed to run "npx expo config --json" from ${mobileDir}: ${error.message}`);
+ process.exit(1);
+}
+
+let config;
+try {
+ config = JSON.parse(raw);
+} catch (error) {
+ console.error(`"npx expo config --json" returned invalid JSON: ${error.message}`);
+ process.exit(1);
+}
+
+const failures = [];
+
+function check(condition, message) {
+ if (!condition) {
+ failures.push(message);
+ }
+}
+
+check(
+ config.ios?.bundleIdentifier === BUNDLE_IDENTIFIER,
+ `ios.bundleIdentifier must be "${BUNDLE_IDENTIFIER}"`
+);
+check(config.android?.package === ANDROID_PACKAGE, `android.package must be "${ANDROID_PACKAGE}"`);
+check(config.scheme === SCHEME, `scheme must be "${SCHEME}"`);
+
+const associatedDomains = config.ios?.associatedDomains ?? [];
+check(
+ associatedDomains.includes(ASSOCIATED_DOMAIN),
+ `ios.associatedDomains must contain "${ASSOCIATED_DOMAIN}"`
+);
+
+const blockedPermissions = config.android?.blockedPermissions ?? [];
+const blockedPermissionsMatch =
+ blockedPermissions.length === BLOCKED_PERMISSIONS.length &&
+ BLOCKED_PERMISSIONS.every(permission => blockedPermissions.includes(permission));
+check(
+ blockedPermissionsMatch,
+ `android.blockedPermissions must equal exactly [${BLOCKED_PERMISSIONS.join(', ')}]`
+);
+
+const pluginNames = (config.plugins ?? []).map(plugin =>
+ Array.isArray(plugin) ? plugin[0] : plugin
+);
+check(pluginNames.includes(SENTRY_PLUGIN), `plugins must include "${SENTRY_PLUGIN}"`);
+
+const extra = config.extra ?? {};
+for (const key of Object.keys(ENV_KEYS)) {
+ const value = extra[key];
+ if (value === undefined || value === null || value === '') {
+ failures.push(`extra.${key} must be present and non-empty`);
+ }
+}
+
+if (failures.length > 0) {
+ console.error('Expo config contract violations:');
+ for (const failure of failures) {
+ console.error(` - ${failure}`);
+ }
+ process.exit(1);
+}
+
+console.log('Expo config contract OK');
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx
new file mode 100644
index 0000000000..d7b10bd570
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx
@@ -0,0 +1,97 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import CodeReviewerScopeRoute from './index';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/code-reviewer/platform-list-screen', () => ({
+ PlatformListScreen: 'PlatformListScreen',
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(CodeReviewerScopeRoute));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+});
+
+describe('CodeReviewerScopeRoute invalid scope', () => {
+ it('renders InvalidRouteState with the profile backTo when scope is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'PlatformListScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the profile backTo when scope is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'PlatformListScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('CodeReviewerScopeRoute valid scope', () => {
+ it('renders the platform list with the parsed scope', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' });
+ const renderer = mountRoute();
+
+ const screen = findByType(renderer.root, 'PlatformListScreen');
+ expect(screen).toHaveLength(1);
+ expect(propOf(screen[0], 'scope')).toBe('personal');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx
index 0ada49eaa5..7d5feaf312 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx
@@ -1,8 +1,16 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
import { PlatformListScreen } from '@/components/code-reviewer/platform-list-screen';
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { parseParam } from '@/lib/route-params';
export default function CodeReviewerScopeRoute() {
- const { scope } = useLocalSearchParams<{ scope: string }>();
+ const { scope: rawScope } = useLocalSearchParams<{ scope: string }>();
+ const scope = parseParam(rawScope);
+
+ if (!scope) {
+ return ;
+ }
+
return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx
new file mode 100644
index 0000000000..2523613b62
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx
@@ -0,0 +1,97 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import CodeReviewerManualReviewRoute from './manual-review';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/code-reviewer/manual-review-screen', () => ({
+ ManualReviewScreen: 'ManualReviewScreen',
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(CodeReviewerManualReviewRoute));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+});
+
+describe('CodeReviewerManualReviewRoute invalid scope', () => {
+ it('renders InvalidRouteState with the profile backTo when scope is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ManualReviewScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the profile backTo when scope is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ManualReviewScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('CodeReviewerManualReviewRoute valid scope', () => {
+ it('renders the manual review screen with the parsed scope', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' });
+ const renderer = mountRoute();
+
+ const screen = findByType(renderer.root, 'ManualReviewScreen');
+ expect(screen).toHaveLength(1);
+ expect(propOf(screen[0], 'scope')).toBe('personal');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx
index 9dca2d7031..17786d4d30 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx
@@ -1,8 +1,16 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
import { ManualReviewScreen } from '@/components/code-reviewer/manual-review-screen';
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { parseParam } from '@/lib/route-params';
export default function CodeReviewerManualReviewRoute() {
- const { scope } = useLocalSearchParams<{ scope: string }>();
+ const { scope: rawScope } = useLocalSearchParams<{ scope: string }>();
+ const scope = parseParam(rawScope);
+
+ if (!scope) {
+ return ;
+ }
+
return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx
new file mode 100644
index 0000000000..f13c106389
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx
@@ -0,0 +1,132 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import CodeReviewerReviewDetailRoute from './[id]';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/code-reviewer/review-detail-screen', () => ({
+ ReviewDetailScreen: 'ReviewDetailScreen',
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(CodeReviewerReviewDetailRoute));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+});
+
+describe('CodeReviewerReviewDetailRoute invalid scope', () => {
+ it('renders InvalidRouteState with the profile backTo when scope is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: undefined, id: 'rev-1' });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the profile backTo when scope is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'], id: 'rev-1' });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('CodeReviewerReviewDetailRoute invalid id', () => {
+ it('renders InvalidRouteState with the scope reviews backTo when id is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe(
+ '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews'
+ );
+ expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the scope reviews backTo when id is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: ['rev-1', 'rev-2'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe(
+ '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews'
+ );
+ expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('CodeReviewerReviewDetailRoute valid params', () => {
+ it('renders the review detail with the parsed scope and id', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: 'rev-1' });
+ const renderer = mountRoute();
+
+ const screen = findByType(renderer.root, 'ReviewDetailScreen');
+ expect(screen).toHaveLength(1);
+ expect(propOf(screen[0], 'scope')).toBe('personal');
+ expect(propOf(screen[0], 'reviewId')).toBe('rev-1');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx
index 7e0c9e0714..104d78a912 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx
@@ -1,8 +1,22 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
import { ReviewDetailScreen } from '@/components/code-reviewer/review-detail-screen';
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { parseParam } from '@/lib/route-params';
export default function CodeReviewerReviewDetailRoute() {
- const { scope, id } = useLocalSearchParams<{ scope: string; id: string }>();
- return ;
+ const { scope: rawScope, id: rawId } = useLocalSearchParams<{ scope: string; id: string }>();
+ const scope = parseParam(rawScope);
+ const reviewId = parseParam(rawId);
+
+ if (!scope || !reviewId) {
+ const backTo = (
+ scope
+ ? `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/reviews`
+ : '/(app)/(tabs)/(3_profile)'
+ ) as Href;
+ return ;
+ }
+
+ return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx
new file mode 100644
index 0000000000..71267742e3
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx
@@ -0,0 +1,97 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import CodeReviewerReviewListRoute from './index';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/code-reviewer/review-list-screen', () => ({
+ ReviewListScreen: 'ReviewListScreen',
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(CodeReviewerReviewListRoute));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+});
+
+describe('CodeReviewerReviewListRoute invalid scope', () => {
+ it('renders InvalidRouteState with the profile backTo when scope is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ReviewListScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the profile backTo when scope is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)');
+ expect(findByType(renderer.root, 'ReviewListScreen')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('CodeReviewerReviewListRoute valid scope', () => {
+ it('renders the review list with the parsed scope', () => {
+ useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' });
+ const renderer = mountRoute();
+
+ const screen = findByType(renderer.root, 'ReviewListScreen');
+ expect(screen).toHaveLength(1);
+ expect(propOf(screen[0], 'scope')).toBe('personal');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx
index a8cfb61054..75e3ac3eb2 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx
@@ -1,8 +1,16 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
import { ReviewListScreen } from '@/components/code-reviewer/review-list-screen';
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { parseParam } from '@/lib/route-params';
export default function CodeReviewerReviewListRoute() {
- const { scope } = useLocalSearchParams<{ scope: string }>();
+ const { scope: rawScope } = useLocalSearchParams<{ scope: string }>();
+ const scope = parseParam(rawScope);
+
+ if (!scope) {
+ return ;
+ }
+
return ;
}
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx
new file mode 100644
index 0000000000..74b155178f
--- /dev/null
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx
@@ -0,0 +1,97 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import MemberLimitRoute from './member-limit';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/organization/member-limit-sheet', () => ({
+ MemberLimitSheet: 'MemberLimitSheet',
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(MemberLimitRoute));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+});
+
+describe('MemberLimitRoute invalid memberId', () => {
+ it('renders InvalidRouteState with the organization backTo when memberId is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ memberId: undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)/organization');
+ expect(findByType(renderer.root, 'MemberLimitSheet')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the organization backTo when memberId is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ memberId: ['m-1', 'm-2'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)/organization');
+ expect(findByType(renderer.root, 'MemberLimitSheet')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('MemberLimitRoute valid memberId', () => {
+ it('renders the member limit sheet with the parsed memberId', () => {
+ useLocalSearchParamsMock.mockReturnValue({ memberId: 'm-1' });
+ const renderer = mountRoute();
+
+ const screen = findByType(renderer.root, 'MemberLimitSheet');
+ expect(screen).toHaveLength(1);
+ expect(propOf(screen[0], 'memberId')).toBe('m-1');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx
index c2f96f816a..71cdff5c60 100644
--- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx
+++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx
@@ -1,8 +1,16 @@
-import { useLocalSearchParams } from 'expo-router';
+import { type Href, useLocalSearchParams } from 'expo-router';
+import { InvalidRouteState } from '@/components/invalid-route-state';
import { MemberLimitSheet } from '@/components/organization/member-limit-sheet';
+import { parseParam } from '@/lib/route-params';
export default function MemberLimitRoute() {
- const { memberId } = useLocalSearchParams<{ memberId: string }>();
+ const { memberId: rawMemberId } = useLocalSearchParams<{ memberId: string }>();
+ const memberId = parseParam(rawMemberId);
+
+ if (!memberId) {
+ return ;
+ }
+
return ;
}
diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
new file mode 100644
index 0000000000..ccd15d31f5
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx
@@ -0,0 +1,201 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import SessionDetailScreen from './[session-id]';
+
+const useLocalSearchParamsMock = vi.hoisted(() => vi.fn());
+const useRouterMock = vi.hoisted(() => vi.fn());
+const useQueryMock = vi.hoisted(() => vi.fn());
+const queryOptionsMock = vi.hoisted(() => vi.fn());
+
+const queryState = vi.hoisted(() => ({
+ isPending: false,
+ isError: false,
+ isFetching: false,
+ error: null as { data?: { code?: string } } | null,
+ data: null as { organization_id?: string } | null,
+ refetch: vi.fn(),
+}));
+
+vi.mock('react-native', () => ({
+ View: 'View',
+}));
+
+vi.mock('expo-router', () => ({
+ useLocalSearchParams: useLocalSearchParamsMock,
+ useRouter: useRouterMock,
+}));
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: useQueryMock,
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ cliSessionsV2: {
+ get: { queryOptions: queryOptionsMock },
+ },
+ }),
+}));
+
+vi.mock('@/components/invalid-route-state', () => ({
+ InvalidRouteState: 'InvalidRouteState',
+}));
+
+vi.mock('@/components/agents/session-detail-content', () => ({
+ SessionDetailContent: 'SessionDetailContent',
+ SessionSkeletonMessages: 'SessionSkeletonMessages',
+}));
+
+vi.mock('@/components/agents/session-connection-indicator', () => ({
+ SessionConnectionIndicator: 'SessionConnectionIndicator',
+}));
+
+vi.mock('@/components/agents/session-context-metrics', () => ({
+ SessionContextMetrics: 'SessionContextMetrics',
+}));
+
+vi.mock('@/components/agents/session-provider', () => ({
+ AgentSessionProvider: 'AgentSessionProvider',
+}));
+
+vi.mock('@/components/agents/session-terminal-error', () => ({
+ buildTerminalErrorCopyText: () => '',
+}));
+
+vi.mock('@/components/agents/use-message-copy', () => ({
+ performCopy: vi.fn(),
+}));
+
+vi.mock('@/components/query-error', () => ({
+ QueryError: 'QueryError',
+}));
+
+vi.mock('@/components/screen-header', () => ({
+ ScreenHeader: 'ScreenHeader',
+}));
+
+vi.mock('@/components/ui/button', () => ({
+ Button: 'Button',
+}));
+
+vi.mock('@/components/ui/text', () => ({
+ Text: 'Text',
+}));
+
+vi.mock('@/lib/spawned-not-found-retry', () => ({
+ shouldRetryNotFoundOnSpawnedRoute: () => false,
+}));
+
+function findByType(
+ root: TestRenderer.ReactTestInstance,
+ type: string
+): TestRenderer.ReactTestInstance[] {
+ return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type);
+}
+
+function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown {
+ if (!instance) {
+ return undefined;
+ }
+ /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */
+ return instance.props[key];
+ /* eslint-enable typescript-eslint/no-unsafe-member-access */
+}
+
+function mountRoute(): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(SessionDetailScreen));
+ });
+ if (!ref.current) {
+ throw new Error('route did not render');
+ }
+ return ref.current;
+}
+
+function queryEnabled(): boolean | undefined {
+ const options = useQueryMock.mock.calls[0]?.[0] as { enabled?: boolean } | undefined;
+ return options?.enabled;
+}
+
+function queryInput(): { session_id?: string } | undefined {
+ return queryOptionsMock.mock.calls[0]?.[0] as { session_id?: string } | undefined;
+}
+
+beforeEach(() => {
+ useLocalSearchParamsMock.mockReset();
+ useRouterMock.mockReset();
+ useRouterMock.mockReturnValue({ replace: vi.fn() });
+ useQueryMock.mockReset();
+ useQueryMock.mockImplementation((options: { enabled?: boolean } | undefined) => {
+ // A disabled TanStack query stays pending forever (`isPending: true` when
+ // `enabled` is false). Model that so the invalid-param tests exercise the
+ // real branch order instead of a skeleton that never resolves.
+ const disabled = options?.enabled === false;
+ return {
+ ...queryState,
+ isPending: disabled ? true : queryState.isPending,
+ };
+ });
+ queryOptionsMock.mockReset();
+ queryOptionsMock.mockReturnValue({});
+ queryState.isPending = false;
+ queryState.isError = false;
+ queryState.isFetching = false;
+ queryState.error = null;
+ queryState.data = null;
+ queryState.refetch.mockClear();
+});
+
+describe('SessionDetailScreen invalid session-id', () => {
+ it('renders InvalidRouteState with the app backTo when session-id is undefined', () => {
+ useLocalSearchParamsMock.mockReturnValue({ 'session-id': undefined });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)');
+ expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0);
+ expect(queryEnabled()).toBe(false);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+
+ it('renders InvalidRouteState with the app backTo when session-id is an array', () => {
+ useLocalSearchParamsMock.mockReturnValue({ 'session-id': ['sess-1', 'sess-2'] });
+ const renderer = mountRoute();
+
+ const invalid = findByType(renderer.root, 'InvalidRouteState');
+ expect(invalid).toHaveLength(1);
+ expect(propOf(invalid[0], 'backTo')).toBe('/(app)');
+ expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0);
+ expect(queryEnabled()).toBe(false);
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
+
+describe('SessionDetailScreen valid session-id', () => {
+ it('renders the session content with the parsed session-id and enables the query', () => {
+ useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' });
+ const renderer = mountRoute();
+
+ const content = findByType(renderer.root, 'SessionDetailContent');
+ expect(content).toHaveLength(1);
+ expect(propOf(content[0], 'sessionId')).toBe('sess-1');
+ expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0);
+ expect(queryEnabled()).toBe(true);
+ expect(queryInput()).toEqual({ session_id: 'sess-1' });
+
+ act(() => {
+ renderer.unmount();
+ });
+ });
+});
diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
index 3785289d63..1ef52d92c4 100644
--- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx
@@ -12,16 +12,18 @@ import { SessionContextMetrics } from '@/components/agents/session-context-metri
import { AgentSessionProvider } from '@/components/agents/session-provider';
import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal-error';
import { performCopy } from '@/components/agents/use-message-copy';
+import { InvalidRouteState } from '@/components/invalid-route-state';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { parseParam } from '@/lib/route-params';
import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry';
import { useTRPC } from '@/lib/trpc';
export default function SessionDetailScreen() {
const {
- 'session-id': sessionId,
+ 'session-id': rawSessionId,
organizationId: routeOrganizationId,
via,
spawned,
@@ -48,6 +50,10 @@ export default function SessionDetailScreen() {
/** Agent mode the spawn was started with; seeds the composer before the CLI reports one. */
mode?: string;
}>();
+ // `session-id` is required: a malformed deep link can hand us `undefined`
+ // or a `string[]`, both of which parseParam rejects. Optional params keep
+ // the existing first-element unwrapping below.
+ const sessionId = parseParam(rawSessionId);
// Param can be string | string[] depending on how the route was opened.
const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam;
const autoSendParam = Array.isArray(autoSendRaw) ? autoSendRaw[0] : autoSendRaw;
@@ -56,7 +62,7 @@ export default function SessionDetailScreen() {
const router = useRouter();
const sessionQuery = useQuery({
...trpc.cliSessionsV2.get.queryOptions(
- { session_id: sessionId },
+ { session_id: sessionId ?? '' },
{
retry: (failureCount, error) =>
shouldRetryNotFoundOnSpawnedRoute({
@@ -85,9 +91,13 @@ export default function SessionDetailScreen() {
retryDelay: 1000,
}
),
- enabled: routeOrganizationId === undefined,
+ enabled: routeOrganizationId === undefined && sessionId !== null,
});
+ if (sessionId === null) {
+ return ;
+ }
+
if (routeOrganizationId === undefined && sessionQuery.isPending) {
return (
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 82fbcb7e6a..a423c51b13 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -35,6 +35,7 @@ import { AppRootProviders } from '@/components/app-root-providers';
import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen';
import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce';
import { useAuth } from '@/lib/auth/auth-context';
+import { resolveBootstrapDecision } from '@/lib/bootstrap-decision';
import { consentModeForSearchParam } from '@/components/consent/consent-mode';
import { checkConsentGate } from '@/lib/consent-gate';
import { subscribeToConsentChanges } from '@/lib/consent';
@@ -474,68 +475,83 @@ function RootLayoutNav() {
}, [hasShareIntent, shareIntent]);
useEffect(() => {
- if (isLoading) {
- return;
- }
+ const decision = resolveBootstrapDecision({
+ isLoading,
+ updateRequired,
+ inForceUpdate,
+ inAuthGroup,
+ hasToken: token != null,
+ userIdLoading,
+ userIdError,
+ consentCheckError: consentCheckError != null,
+ consentChecked,
+ needsConsent,
+ onConsentRoute,
+ onConsentReviewRoute,
+ });
- if (updateRequired) {
- if (!inForceUpdate) {
+ // Replaces the old inline if-chain (resolveBootstrapTag in
+ // src/lib/bootstrap-decision.ts). Remove this switch when the decision
+ // module owns all bootstrap routing.
+ switch (decision.tag) {
+ case 'wait-loading':
+ case 'wait-user-consent': {
+ return;
+ }
+ case 'redirect-force-update': {
router.replace('/force-update');
- } else {
+ return;
+ }
+ case 'settle-force-update': {
markStartupComplete('force-update');
setStartupFinished(true);
+ return;
}
- return;
- }
-
- if (inForceUpdate) {
- router.replace('/(app)');
- return;
- }
-
- if (!token) {
- if (inAuthGroup) {
+ case 'exit-force-update': {
+ router.replace('/(app)');
+ return;
+ }
+ case 'settle-login': {
markStartupComplete('login');
setStartupFinished(true);
- } else {
+ return;
+ }
+ case 'redirect-login': {
router.replace('/(auth)/login');
+ return;
}
- } else {
- if (userIdError) {
+ case 'settle-user-error': {
markStartupComplete('user-error');
setStartupFinished(true);
return;
}
-
- if (consentCheckError) {
+ case 'settle-consent-error': {
markStartupComplete('consent-error');
setStartupFinished(true);
return;
}
-
- if (userIdLoading || !consentChecked) {
+ case 'settle-consent': {
+ markStartupComplete('consent');
+ setStartupFinished(true);
return;
}
-
- if (needsConsent) {
- if (onConsentRoute) {
- markStartupComplete('consent');
- setStartupFinished(true);
- } else {
- router.replace('/(app)/consent' as Href);
- }
+ case 'redirect-consent': {
+ router.replace('/(app)/consent' as Href);
return;
}
-
- if ((onConsentRoute && !onConsentReviewRoute) || inAuthGroup) {
+ case 'redirect-app': {
router.replace('/(app)');
return;
}
-
- markStartupComplete('app');
- setStartupFinished(true);
- // Deep-link navigation is owned by the pendingDeepLink effect below.
- // Share-gate open is owned by the pendingShareId effect + isShellReadyForShare.
+ case 'settle-app': {
+ markStartupComplete('app');
+ setStartupFinished(true);
+ // Deep-link navigation is owned by the pendingDeepLink effect below.
+ // Share-gate open is owned by the pendingShareId effect + isShellReadyForShare.
+ break;
+ }
+ default:
+ // Unreachable: BootstrapDecisionTag is a closed union.
}
}, [
token,
@@ -616,30 +632,25 @@ function RootLayoutNav() {
});
}, [startupFinished, token, consentChecked, needsConsent, optionalConsent, postHogReady]);
- const needsForceUpdate = updateRequired && !inForceUpdate;
- const showingForceUpdate = updateRequired && inForceUpdate;
- const needsAuth = !token && !inAuthGroup;
- const needsAppRedirect = token != null && inAuthGroup;
- const hasUserBootstrapError = token != null && userIdError;
- const hasConsentBootstrapError = token != null && consentCheckError !== null;
- const hasBootstrapError = hasUserBootstrapError || hasConsentBootstrapError;
- const consentLoading =
- token != null && !consentChecked && !inAuthGroup && !inForceUpdate && !onConsentRoute;
- const needsConsentRedirect = consentChecked && needsConsent && !onConsentRoute;
-
- const needsRedirect =
- !isLoading &&
- (needsForceUpdate ||
- (!showingForceUpdate && (needsAuth || needsAppRedirect || needsConsentRedirect)));
-
// Always keep Slot mounted so Expo Router's navigation tree stays
// initialised — returning null unmounts it and breaks router.replace.
// The native splash screen covers everything during initial load, and
// opacity 0 hides the wrong screen during redirects.
- const hidden =
- !hasUserBootstrapError &&
- !hasConsentBootstrapError &&
- (isLoading || needsRedirect || consentLoading);
+ const { hasUserBootstrapError, hasConsentBootstrapError, hasBootstrapError, hidden } =
+ resolveBootstrapDecision({
+ isLoading,
+ updateRequired,
+ inForceUpdate,
+ inAuthGroup,
+ hasToken: token != null,
+ userIdLoading,
+ userIdError,
+ consentCheckError: consentCheckError != null,
+ consentChecked,
+ needsConsent,
+ onConsentRoute,
+ onConsentReviewRoute,
+ });
// Hidden root-route entry contract (D17): while `hidden`, the wrapper leaves
// both accessibility trees. On the hidden → visible transition,
diff --git a/apps/mobile/src/components/home/home-screen.mounted.test.tsx b/apps/mobile/src/components/home/home-screen.mounted.test.tsx
index 573cadffff..2c713e04d3 100644
--- a/apps/mobile/src/components/home/home-screen.mounted.test.tsx
+++ b/apps/mobile/src/components/home/home-screen.mounted.test.tsx
@@ -9,6 +9,8 @@ const hasSessions = vi.hoisted(() => ({ value: true }));
const activeIsError = vi.hoisted(() => ({ value: false }));
const storedIsError = vi.hoisted(() => ({ value: false }));
const storedIsSuccess = vi.hoisted(() => ({ value: true }));
+const sessionsLoading = vi.hoisted(() => ({ value: false }));
+const orgLoaded = vi.hoisted(() => ({ value: true }));
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
@@ -37,6 +39,9 @@ vi.mock('@/components/home/greeting', () => ({
vi.mock('@/components/home/new-task-button', () => ({
NewTaskButton: 'NewTaskButton',
}));
+vi.mock('@/components/home/product-choices', () => ({
+ ProductChoices: 'ProductChoices',
+}));
vi.mock('@/components/query-error', () => ({
QueryError: 'QueryError',
}));
@@ -52,7 +57,7 @@ vi.mock('@/components/ui/skeleton', () => ({
vi.mock('@/lib/hooks/use-agent-sessions', () => ({
useAgentSessions: () => ({
activeSessions: [],
- isLoading: false,
+ isLoading: sessionsLoading.value,
storedSessions: [{}],
storedIsError: storedIsError.value,
storedIsSuccess: storedIsSuccess.value,
@@ -61,7 +66,7 @@ vi.mock('@/lib/hooks/use-agent-sessions', () => ({
}),
}));
vi.mock('@/lib/organization-context', () => ({
- useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }),
+ useOrganization: () => ({ organizationId: 'org-1', isLoaded: orgLoaded.value }),
}));
function nodeCount(root: TestRenderer.ReactTestInstance, type: string): number {
@@ -94,9 +99,12 @@ async function mountHome(): Promise {
describe('HomeScreen composition', () => {
it('renders the sessions section and new-task button when sessions are present', async () => {
hasSessions.value = true;
+ sessionsLoading.value = false;
+ orgLoaded.value = true;
const renderer = await mountHome();
expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(1);
expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(1);
+ expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1);
expect(nodeCount(renderer.root, 'Skeleton')).toBe(0);
await act(async () => {
@@ -110,11 +118,14 @@ describe('HomeScreen composition', () => {
storedIsError.value = false;
storedIsSuccess.value = true;
activeIsError.value = false;
+ sessionsLoading.value = false;
+ orgLoaded.value = true;
const renderer = await mountHome();
expect(nodeCount(renderer.root, 'AgentsPromoCard')).toBe(1);
expect(nodeCount(renderer.root, 'QueryError')).toBe(0);
expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(0);
expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(0);
+ expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1);
await act(async () => {
await Promise.resolve();
@@ -127,6 +138,8 @@ describe('HomeScreen composition', () => {
storedIsError.value = false;
storedIsSuccess.value = true;
activeIsError.value = true;
+ sessionsLoading.value = false;
+ orgLoaded.value = true;
const renderer = await mountHome();
const queryError = findNode(renderer.root, 'QueryError');
expect(queryError).toBeDefined();
@@ -135,6 +148,21 @@ describe('HomeScreen composition', () => {
expect(nodeCount(renderer.root, 'AgentsPromoCard')).toBe(0);
expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(0);
expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(0);
+ expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1);
+
+ await act(async () => {
+ await Promise.resolve();
+ renderer.unmount();
+ });
+ });
+
+ it('does not render product choices while loading', async () => {
+ sessionsLoading.value = true;
+ orgLoaded.value = true;
+ const renderer = await mountHome();
+ expect(nodeCount(renderer.root, 'ProductChoices')).toBe(0);
+ expect(nodeCount(renderer.root, 'Skeleton')).toBeGreaterThan(0);
+ sessionsLoading.value = false;
await act(async () => {
await Promise.resolve();
diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts
index a46faf5250..f6fff4a7fe 100644
--- a/apps/mobile/src/components/home/home-screen.test.ts
+++ b/apps/mobile/src/components/home/home-screen.test.ts
@@ -28,6 +28,9 @@ vi.mock('@/components/home/greeting', () => ({
vi.mock('@/components/home/new-task-button', () => ({
NewTaskButton: () => null,
}));
+vi.mock('@/components/home/product-choices', () => ({
+ ProductChoices: () => null,
+}));
vi.mock('@/components/query-error', () => ({
QueryError: () => null,
}));
diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx
index 80d6f7da49..1bc6a0719b 100644
--- a/apps/mobile/src/components/home/home-screen.tsx
+++ b/apps/mobile/src/components/home/home-screen.tsx
@@ -12,6 +12,7 @@ import {
import { AgentsPromoCard } from '@/components/home/agents-promo-card';
import { buildTimedGreeting } from '@/components/home/greeting';
import { NewTaskButton } from '@/components/home/new-task-button';
+import { ProductChoices } from '@/components/home/product-choices';
import { QueryError } from '@/components/query-error';
import { ScreenHeader } from '@/components/screen-header';
import { Skeleton } from '@/components/ui/skeleton';
@@ -91,6 +92,8 @@ export function HomeScreen() {
) : null}
+
+
)}
diff --git a/apps/mobile/src/components/home/product-choices.mounted.test.tsx b/apps/mobile/src/components/home/product-choices.mounted.test.tsx
new file mode 100644
index 0000000000..8493fc7ae0
--- /dev/null
+++ b/apps/mobile/src/components/home/product-choices.mounted.test.tsx
@@ -0,0 +1,130 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ProductChoices } from '@/components/home/product-choices';
+import { getCodeReviewerProfilePath, getPrReviewEntryPath } from '@/lib/profile-agent-navigation';
+import { getSecurityAgentPath } from '@/lib/security-agent';
+
+const push = vi.hoisted(() => vi.fn());
+const prReviewEnabled = vi.hoisted(() => ({ value: true }));
+
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ push }),
+}));
+vi.mock('react-native', () => ({
+ View: 'View',
+}));
+vi.mock('@/components/ui/configure-row', () => ({
+ ConfigureRow: 'ConfigureRow',
+}));
+vi.mock('@/components/home/section-header', () => ({
+ SectionHeader: 'SectionHeader',
+}));
+vi.mock('@/components/ui/icons', () => ({
+ GitMerge: 'GitMerge',
+ GitPullRequest: 'GitPullRequest',
+ ShieldCheck: 'ShieldCheck',
+}));
+vi.mock('@/lib/analytics/posthog', () => ({
+ FEATURE_FLAG_PR_REVIEW: 'mobile-pr-review',
+ useFeatureFlag: () => prReviewEnabled.value,
+}));
+
+function rows(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance[] {
+ return root.findAll(
+ node => typeof node.type === 'string' && (node.type as string) === 'ConfigureRow'
+ );
+}
+
+function rowByTitle(
+ root: TestRenderer.ReactTestInstance,
+ title: string
+): TestRenderer.ReactTestInstance | undefined {
+ return rows(root).find(row => row.props.title === title);
+}
+
+function pressRow(root: TestRenderer.ReactTestInstance, title: string): void {
+ const onPress = rowByTitle(root, title)?.props.onPress as (() => void) | undefined;
+ onPress?.();
+}
+
+async function mountProductChoices(
+ organizationId: string | null
+): Promise {
+ const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = {
+ current: undefined,
+ };
+ await act(async () => {
+ await Promise.resolve();
+ rendererRef.current = TestRenderer.create(createElement(ProductChoices, { organizationId }));
+ });
+ const renderer = rendererRef.current;
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+async function unmount(renderer: TestRenderer.ReactTestRenderer): Promise {
+ await act(async () => {
+ await Promise.resolve();
+ renderer.unmount();
+ });
+}
+
+describe('ProductChoices', () => {
+ it('renders all three product rows when PR Review is enabled', async () => {
+ prReviewEnabled.value = true;
+ const renderer = await mountProductChoices('org-1');
+ expect(rows(renderer.root).map(row => row.props.title)).toEqual([
+ 'Code Reviewer',
+ 'Security Agent',
+ 'PR Review',
+ ]);
+ await unmount(renderer);
+ });
+
+ it('navigates each row to its product path', async () => {
+ prReviewEnabled.value = true;
+ push.mockClear();
+ const renderer = await mountProductChoices('org-1');
+
+ pressRow(renderer.root, 'Code Reviewer');
+ expect(push).toHaveBeenCalledWith(getCodeReviewerProfilePath('org-1'));
+
+ pressRow(renderer.root, 'Security Agent');
+ expect(push).toHaveBeenCalledWith(getSecurityAgentPath('org-1'));
+
+ pressRow(renderer.root, 'PR Review');
+ expect(push).toHaveBeenCalledWith(getPrReviewEntryPath());
+
+ await unmount(renderer);
+ });
+
+ it('falls back to the personal scope when no organization is selected', async () => {
+ prReviewEnabled.value = true;
+ push.mockClear();
+ const renderer = await mountProductChoices(null);
+
+ pressRow(renderer.root, 'Code Reviewer');
+ expect(push).toHaveBeenCalledWith(getCodeReviewerProfilePath('personal'));
+
+ pressRow(renderer.root, 'Security Agent');
+ expect(push).toHaveBeenCalledWith(getSecurityAgentPath('personal'));
+
+ await unmount(renderer);
+ });
+
+ it('hides PR Review when the flag is false', async () => {
+ prReviewEnabled.value = false;
+ const renderer = await mountProductChoices('org-1');
+ expect(rows(renderer.root).map(row => row.props.title)).toEqual([
+ 'Code Reviewer',
+ 'Security Agent',
+ ]);
+ prReviewEnabled.value = true;
+ await unmount(renderer);
+ });
+});
diff --git a/apps/mobile/src/components/home/product-choices.tsx b/apps/mobile/src/components/home/product-choices.tsx
new file mode 100644
index 0000000000..661cc8cc66
--- /dev/null
+++ b/apps/mobile/src/components/home/product-choices.tsx
@@ -0,0 +1,59 @@
+import { PERSONAL_SECURITY_SCOPE } from '@kilocode/app-shared/security-agent';
+import { useRouter } from 'expo-router';
+import { View } from 'react-native';
+
+import { SectionHeader } from '@/components/home/section-header';
+import { ConfigureRow } from '@/components/ui/configure-row';
+import { GitMerge, GitPullRequest, ShieldCheck } from '@/components/ui/icons';
+import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog';
+import { getCodeReviewerProfilePath, getPrReviewEntryPath } from '@/lib/profile-agent-navigation';
+import { getSecurityAgentPath } from '@/lib/security-agent';
+
+type ProductChoicesProps = {
+ organizationId: string | null;
+};
+
+export function ProductChoices({ organizationId }: Readonly) {
+ const router = useRouter();
+ const prReviewEnabled = useFeatureFlag(FEATURE_FLAG_PR_REVIEW, true);
+ const scope = organizationId ?? PERSONAL_SECURITY_SCOPE;
+
+ return (
+
+
+
+ {
+ router.push(getCodeReviewerProfilePath(scope));
+ }}
+ />
+ {
+ router.push(getSecurityAgentPath(scope));
+ }}
+ />
+ {prReviewEnabled ? (
+ {
+ router.push(getPrReviewEntryPath());
+ }}
+ />
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
new file mode 100644
index 0000000000..b22f16ff3e
--- /dev/null
+++ b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx
@@ -0,0 +1,335 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */
+import { createElement } from 'react';
+import { act, type ReactTestInstance } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ProfileScreen } from '@/components/profile-screen';
+import { createTestQueryClient, renderWithProviders, waitFor } from '@/test/render-with-providers';
+
+// ── Hoisted mocks ──────────────────────────────────────────────────────────
+
+const providersQueryFn = vi.hoisted(() => vi.fn());
+const organizationsQueryFn = vi.hoisted(() => vi.fn());
+const signOutFn = vi.hoisted(() => vi.fn());
+const routerPush = vi.hoisted(() => vi.fn());
+const keys = vi.hoisted(() => ({
+ providers: ['user', 'getAuthProviders'],
+ organizations: ['organizations', 'list'],
+}));
+const authState = vi.hoisted(() => ({ token: 'token-1' as string | null }));
+const interactionState = vi.hoisted(() => ({
+ storedCallback: undefined as (() => void) | undefined,
+ cancel: vi.fn(),
+}));
+// eslint-disable-next-line promise/prefer-await-to-callbacks -- the mock must capture the callback so the test can flush it
+const captureInteraction = vi.hoisted(() => (cb: () => void) => {
+ interactionState.storedCallback = cb;
+ return { cancel: interactionState.cancel };
+});
+const getProfileAgentScopeMock = vi.hoisted(() => vi.fn());
+
+vi.mock('react-native', () => ({
+ Alert: { alert: vi.fn() },
+ View: 'View',
+ InteractionManager: {
+ runAfterInteractions: vi.fn(captureInteraction),
+ },
+}));
+
+vi.mock('react-native-reanimated', () => ({
+ default: { View: 'Animated.View' },
+ FadeIn: { duration: vi.fn() },
+ FadeOut: { duration: vi.fn() },
+ LinearTransition: {},
+}));
+
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ push: routerPush }),
+}));
+
+vi.mock('expo-application', () => ({
+ nativeApplicationVersion: '1.0.0',
+ nativeBuildVersion: '1',
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ user: {
+ getAuthProviders: {
+ queryOptions: () => ({ queryKey: keys.providers, queryFn: providersQueryFn }),
+ },
+ },
+ organizations: {
+ list: {
+ queryOptions: () => ({ queryKey: keys.organizations, queryFn: organizationsQueryFn }),
+ },
+ },
+ }),
+}));
+
+vi.mock('@/lib/auth/auth-context', () => ({
+ useAuth: () => ({ signOut: signOutFn, token: authState.token }),
+}));
+
+vi.mock('@/lib/organization-context', () => ({
+ useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }),
+}));
+
+vi.mock('@/lib/analytics/posthog', () => ({
+ FEATURE_FLAG_PR_REVIEW: 'mobile-pr-review',
+ useFeatureFlag: () => true,
+}));
+
+vi.mock('@/components/use-delete-account', () => ({
+ useDeleteAccount: () => ({
+ phase: 'idle',
+ isPending: false,
+ devCode: null,
+ beginDelete: vi.fn(),
+ submitCode: vi.fn(),
+ setCode: vi.fn(),
+ }),
+}));
+
+vi.mock('@/lib/hooks/use-current-user-id', () => ({
+ useCurrentUserId: () => ({ userId: 'user-1' }),
+}));
+
+vi.mock('@/lib/hooks/use-theme-colors', () => ({
+ useThemeColors: () => ({ mutedForeground: '#000000' }),
+}));
+
+vi.mock('@/lib/profile-agent-navigation', () => ({
+ getCodeReviewerProfilePath: () => '/code-reviewer',
+ getProfileAgentScope: getProfileAgentScopeMock,
+ getPrReviewEntryPath: () => '/pr-review',
+}));
+
+vi.mock('@/lib/security-agent', () => ({
+ getSecurityAgentPath: () => '/security-agent',
+}));
+
+vi.mock('@/lib/feedback', () => ({
+ showFeedbackPrompt: vi.fn(),
+}));
+
+vi.mock('@/components/ui/icons', () => ({
+ Building2: 'Building2',
+ GitMerge: 'GitMerge',
+ GitPullRequest: 'GitPullRequest',
+ KeyRound: 'KeyRound',
+ Lock: 'Lock',
+ LogOut: 'LogOut',
+ MessageSquare: 'MessageSquare',
+ ShieldCheck: 'ShieldCheck',
+ SlidersHorizontal: 'SlidersHorizontal',
+ Smartphone: 'Smartphone',
+ Trash2: 'Trash2',
+}));
+
+vi.mock('@/components/profile-action-tile', () => ({ ActionTile: 'ActionTile' }));
+vi.mock('@/components/profile-credits-card', () => ({ CreditsCard: 'CreditsCard' }));
+vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' }));
+vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null }));
+vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' }));
+vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
+vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' }));
+vi.mock('@/components/ui/form-field', () => ({ FormField: 'FormField' }));
+vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function nodeCount(root: ReactTestInstance, type: string): number {
+ return root.findAll(node => typeof node.type === 'string' && node.type === type).length;
+}
+
+function findNode(root: ReactTestInstance, type: string): ReactTestInstance | undefined {
+ return root.findAll(node => typeof node.type === 'string' && node.type === type)[0];
+}
+
+function nodeCountWithChildren(root: ReactTestInstance, type: string, children: string): number {
+ return root.findAll(
+ node => typeof node.type === 'string' && node.type === type && node.props.children === children
+ ).length;
+}
+
+function findConfigureRows(root: ReactTestInstance, title: string): ReactTestInstance[] {
+ return root.findAll(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) === 'ConfigureRow' &&
+ node.props.title === title
+ );
+}
+
+async function mountProfile() {
+ const result = await renderWithProviders(createElement(ProfileScreen));
+ return result;
+}
+
+function flushInteractions() {
+ const run = interactionState.storedCallback;
+ if (!run) {
+ throw new Error('runAfterInteractions callback was not captured');
+ }
+ act(() => {
+ run();
+ });
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────
+
+describe('ProfileScreen deferred queries', () => {
+ beforeEach(() => {
+ providersQueryFn.mockReset();
+ organizationsQueryFn.mockReset();
+ signOutFn.mockReset();
+ routerPush.mockReset();
+ authState.token = 'token-1';
+ interactionState.storedCallback = undefined;
+ interactionState.cancel.mockReset();
+ getProfileAgentScopeMock.mockReset();
+ getProfileAgentScopeMock.mockReturnValue('personal');
+ });
+
+ it('defers both queries until interactions settle, showing the skeleton first', async () => {
+ providersQueryFn.mockResolvedValue({ providers: [] });
+ organizationsQueryFn.mockResolvedValue([]);
+
+ const { renderer, unmount } = await mountProfile();
+
+ // Before the flush: neither query fired, the skeleton shows, and the agent
+ // rows are held disabled (refreshing argument is true).
+ expect(providersQueryFn).not.toHaveBeenCalled();
+ expect(organizationsQueryFn).not.toHaveBeenCalled();
+ expect(nodeCount(renderer.root, 'Skeleton')).toBe(1);
+ expect(getProfileAgentScopeMock.mock.calls.at(-1)?.[2]).toBe(true);
+
+ flushInteractions();
+
+ await waitFor(
+ () => providersQueryFn.mock.calls.length > 0 && organizationsQueryFn.mock.calls.length > 0
+ );
+ expect(providersQueryFn).toHaveBeenCalledTimes(1);
+ expect(organizationsQueryFn).toHaveBeenCalledTimes(1);
+
+ // Once the deferred fetch settles, the refreshing argument is false.
+ await waitFor(() => getProfileAgentScopeMock.mock.calls.at(-1)?.[2] === false);
+
+ unmount();
+ });
+
+ it('renders cached providers without the skeleton before the flush', async () => {
+ const queryClient = createTestQueryClient();
+ queryClient.setQueryData(keys.providers, {
+ providers: [{ provider: 'github', email: 'dev@kilo.ai' }],
+ });
+ queryClient.setQueryData(keys.organizations, [
+ { organizationId: 'org-1', organizationName: 'Kilo', role: 'admin' },
+ ]);
+
+ const { renderer, unmount } = await renderWithProviders(createElement(ProfileScreen), {
+ queryClient,
+ });
+
+ expect(providersQueryFn).not.toHaveBeenCalled();
+ expect(nodeCount(renderer.root, 'Skeleton')).toBe(0);
+ expect(findConfigureRows(renderer.root, 'GitHub').length).toBe(1);
+
+ unmount();
+ });
+
+ it('renders QueryError with retry after the deferred providers query fails', async () => {
+ providersQueryFn.mockRejectedValue(new Error('boom'));
+ organizationsQueryFn.mockResolvedValue([]);
+
+ const { renderer, unmount } = await mountProfile();
+
+ flushInteractions();
+ await waitFor(() => nodeCount(renderer.root, 'QueryError') > 0);
+
+ const queryError = findNode(renderer.root, 'QueryError');
+ expect(queryError?.props.title).toBe('Could not load accounts');
+ expect(typeof queryError?.props.onRetry).toBe('function');
+
+ unmount();
+ });
+
+ it('does not fire the queries when unauthenticated, even after the flush', async () => {
+ authState.token = null;
+
+ const { unmount } = await mountProfile();
+
+ flushInteractions();
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(providersQueryFn).not.toHaveBeenCalled();
+ expect(organizationsQueryFn).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('cancels the interaction handle on unmount', async () => {
+ const { unmount } = await mountProfile();
+
+ expect(interactionState.cancel).not.toHaveBeenCalled();
+ unmount();
+ expect(interactionState.cancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders a cached providers error without the skeleton or a refire before the flush', async () => {
+ providersQueryFn.mockRejectedValue(new Error('boom'));
+ organizationsQueryFn.mockResolvedValue([]);
+
+ const queryClient = createTestQueryClient();
+ const first = await renderWithProviders(createElement(ProfileScreen), { queryClient });
+
+ // Settle the first mount into the error state so the error is cached.
+ flushInteractions();
+ await waitFor(() => nodeCount(first.renderer.root, 'QueryError') > 0);
+
+ // Unmount without clearing the cache (the harness `unmount` clears it).
+ act(() => {
+ first.renderer.unmount();
+ });
+
+ // The error is now cached; reset the call history so a refire is observable.
+ providersQueryFn.mockClear();
+
+ const second = await renderWithProviders(createElement(ProfileScreen), { queryClient });
+
+ // Before the flush: the cached error renders, no skeleton, and no refire.
+ expect(nodeCount(second.renderer.root, 'QueryError')).toBe(1);
+ expect(nodeCount(second.renderer.root, 'Skeleton')).toBe(0);
+ expect(providersQueryFn).not.toHaveBeenCalled();
+
+ second.unmount();
+ });
+
+ it('hides the linked-accounts section when the deferred fetch settles empty', async () => {
+ providersQueryFn.mockResolvedValue({ providers: [] });
+ organizationsQueryFn.mockResolvedValue([]);
+
+ const { renderer, unmount } = await mountProfile();
+
+ flushInteractions();
+ await waitFor(
+ () => providersQueryFn.mock.calls.length > 0 && organizationsQueryFn.mock.calls.length > 0
+ );
+
+ // After the deferred fetch settles empty: no skeleton and no header.
+ await waitFor(
+ () =>
+ nodeCountWithChildren(renderer.root, 'Text', 'Linked accounts') === 0 &&
+ nodeCount(renderer.root, 'Skeleton') === 0
+ );
+
+ expect(nodeCount(renderer.root, 'Skeleton')).toBe(0);
+ expect(nodeCountWithChildren(renderer.root, 'Text', 'Linked accounts')).toBe(0);
+
+ unmount();
+ });
+});
diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx
index 154107d563..9268b60385 100644
--- a/apps/mobile/src/components/profile-screen.tsx
+++ b/apps/mobile/src/components/profile-screen.tsx
@@ -32,6 +32,7 @@ import { useDeleteAccount } from '@/components/use-delete-account';
import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog';
import { useAuth } from '@/lib/auth/auth-context';
import { showFeedbackPrompt } from '@/lib/feedback';
+import { useAfterInteractions } from '@/lib/hooks/use-after-interactions';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useOrganization } from '@/lib/organization-context';
@@ -72,6 +73,7 @@ export function ProfileScreen() {
const colors = useThemeColors();
const { organizationId, isLoaded: organizationContextLoaded } = useOrganization();
const isAuthenticated = token != null;
+ const afterInteractions = useAfterInteractions();
const prReviewEnabled = useFeatureFlag(FEATURE_FLAG_PR_REVIEW, true);
const {
data,
@@ -81,7 +83,7 @@ export function ProfileScreen() {
refetch: refetchProviders,
} = useQuery({
...trpc.user.getAuthProviders.queryOptions(),
- enabled: isAuthenticated,
+ enabled: isAuthenticated && afterInteractions,
});
const {
data: orgs,
@@ -90,10 +92,10 @@ export function ProfileScreen() {
refetch: refetchOrganizations,
} = useQuery({
...trpc.organizations.list.queryOptions(),
- enabled: isAuthenticated,
+ enabled: isAuthenticated && afterInteractions,
});
const agentScope = organizationContextLoaded
- ? getProfileAgentScope(organizationId, orgs, organizationsFetching)
+ ? getProfileAgentScope(organizationId, orgs, organizationsFetching || !afterInteractions)
: undefined;
const selectedOrg = orgs?.find(org => org.organizationId === organizationId);
const orgRole = selectedOrg?.role;
@@ -240,13 +242,16 @@ export function ProfileScreen() {
{/* No layout animation on this section: siblings above mount/resize
asynchronously; LinearTransition would animate this container's
position lag as a visible header overlap. Opacity fades are safe. */}
- {(isLoading || providersError || (data?.providers.length ?? 0) > 0) && (
+ {(providersError ||
+ (data?.providers.length ?? 0) > 0 ||
+ isLoading ||
+ (!afterInteractions && !data)) && (
Linked accounts
- {isLoading && (
+ {(isLoading || !afterInteractions) && !data && !providersError && (
diff --git a/apps/mobile/src/lib/bootstrap-decision.test.ts b/apps/mobile/src/lib/bootstrap-decision.test.ts
new file mode 100644
index 0000000000..d6b8136198
--- /dev/null
+++ b/apps/mobile/src/lib/bootstrap-decision.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveBootstrapDecision } from './bootstrap-decision';
+
+// The settled, signed-in, consent-granted, app-ready state: every guard passes
+// and the decision falls through to `settle-app`.
+const ready = {
+ isLoading: false,
+ updateRequired: false,
+ inForceUpdate: false,
+ inAuthGroup: false,
+ hasToken: true,
+ userIdLoading: false,
+ userIdError: false,
+ consentCheckError: false,
+ consentChecked: true,
+ needsConsent: false,
+ onConsentRoute: false,
+ onConsentReviewRoute: false,
+} as const;
+
+describe('resolveBootstrapDecision tag', () => {
+ it('waits while loading', () => {
+ expect(resolveBootstrapDecision({ ...ready, isLoading: true }).tag).toBe('wait-loading');
+ });
+
+ it('redirects to force-update when an update is required off the route', () => {
+ expect(resolveBootstrapDecision({ ...ready, updateRequired: true }).tag).toBe(
+ 'redirect-force-update'
+ );
+ });
+
+ it('settles force-update when an update is required on the route', () => {
+ expect(
+ resolveBootstrapDecision({ ...ready, updateRequired: true, inForceUpdate: true }).tag
+ ).toBe('settle-force-update');
+ });
+
+ it('exits force-update when the route is stale', () => {
+ expect(resolveBootstrapDecision({ ...ready, inForceUpdate: true }).tag).toBe(
+ 'exit-force-update'
+ );
+ });
+
+ it('settles login without a token in the auth group', () => {
+ expect(resolveBootstrapDecision({ ...ready, hasToken: false, inAuthGroup: true }).tag).toBe(
+ 'settle-login'
+ );
+ });
+
+ it('redirects to login without a token outside the auth group', () => {
+ expect(resolveBootstrapDecision({ ...ready, hasToken: false }).tag).toBe('redirect-login');
+ });
+
+ it('settles the user error when the user id failed', () => {
+ expect(resolveBootstrapDecision({ ...ready, userIdError: true }).tag).toBe('settle-user-error');
+ });
+
+ it('settles the consent error when the consent check failed', () => {
+ expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).tag).toBe(
+ 'settle-consent-error'
+ );
+ });
+
+ it('waits while the user id is loading', () => {
+ expect(resolveBootstrapDecision({ ...ready, userIdLoading: true }).tag).toBe(
+ 'wait-user-consent'
+ );
+ });
+
+ it('waits before consent is checked', () => {
+ expect(resolveBootstrapDecision({ ...ready, consentChecked: false }).tag).toBe(
+ 'wait-user-consent'
+ );
+ });
+
+ it('settles consent when needed on the consent route', () => {
+ expect(
+ resolveBootstrapDecision({ ...ready, needsConsent: true, onConsentRoute: true }).tag
+ ).toBe('settle-consent');
+ });
+
+ it('redirects to consent when needed off the consent route', () => {
+ expect(resolveBootstrapDecision({ ...ready, needsConsent: true }).tag).toBe('redirect-consent');
+ });
+
+ it('redirects to the app on a non-review consent route', () => {
+ expect(
+ resolveBootstrapDecision({ ...ready, onConsentRoute: true, onConsentReviewRoute: false }).tag
+ ).toBe('redirect-app');
+ });
+
+ it('redirects to the app in the auth group', () => {
+ expect(resolveBootstrapDecision({ ...ready, inAuthGroup: true }).tag).toBe('redirect-app');
+ });
+
+ it('settles the app on the success tail', () => {
+ expect(resolveBootstrapDecision(ready).tag).toBe('settle-app');
+ });
+});
+
+describe('resolveBootstrapDecision derivations', () => {
+ it('derives hasUserBootstrapError from token and user error', () => {
+ expect(resolveBootstrapDecision(ready).hasUserBootstrapError).toBe(false);
+ expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hasUserBootstrapError).toBe(
+ true
+ );
+ expect(
+ resolveBootstrapDecision({ ...ready, userIdError: true, hasToken: false })
+ .hasUserBootstrapError
+ ).toBe(false);
+ });
+
+ it('derives hasConsentBootstrapError from token and consent error', () => {
+ expect(resolveBootstrapDecision(ready).hasConsentBootstrapError).toBe(false);
+ expect(
+ resolveBootstrapDecision({ ...ready, consentCheckError: true }).hasConsentBootstrapError
+ ).toBe(true);
+ expect(
+ resolveBootstrapDecision({ ...ready, consentCheckError: true, hasToken: false })
+ .hasConsentBootstrapError
+ ).toBe(false);
+ });
+
+ it('derives hasBootstrapError as the union of both error flags', () => {
+ expect(resolveBootstrapDecision(ready).hasBootstrapError).toBe(false);
+ expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hasBootstrapError).toBe(true);
+ expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).hasBootstrapError).toBe(
+ true
+ );
+ });
+
+ it('derives hidden from loading, redirect, or consent loading, unless a bootstrap error shows', () => {
+ expect(resolveBootstrapDecision(ready).hidden).toBe(false);
+ expect(resolveBootstrapDecision({ ...ready, isLoading: true }).hidden).toBe(true);
+ expect(resolveBootstrapDecision({ ...ready, needsConsent: true }).hidden).toBe(true);
+ expect(resolveBootstrapDecision({ ...ready, consentChecked: false }).hidden).toBe(true);
+ expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hidden).toBe(false);
+ expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).hidden).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/bootstrap-decision.ts b/apps/mobile/src/lib/bootstrap-decision.ts
new file mode 100644
index 0000000000..fcdb19197f
--- /dev/null
+++ b/apps/mobile/src/lib/bootstrap-decision.ts
@@ -0,0 +1,109 @@
+/**
+ * Pure bootstrap routing decision, extracted from the inline if-chain in
+ * `_layout.tsx`'s auth effect. Consumed by BOTH the routing effect and the
+ * render gating derivations. Keep in lockstep with that effect — do not
+ * refactor either in isolation.
+ */
+
+type BootstrapDecisionTag =
+ | 'wait-loading'
+ | 'redirect-force-update'
+ | 'settle-force-update'
+ | 'exit-force-update'
+ | 'settle-login'
+ | 'redirect-login'
+ | 'settle-user-error'
+ | 'settle-consent-error'
+ | 'wait-user-consent'
+ | 'settle-consent'
+ | 'redirect-consent'
+ | 'redirect-app'
+ | 'settle-app';
+
+export type BootstrapDecisionInput = {
+ isLoading: boolean;
+ updateRequired: boolean;
+ inForceUpdate: boolean;
+ inAuthGroup: boolean;
+ hasToken: boolean;
+ userIdLoading: boolean;
+ userIdError: boolean;
+ consentCheckError: boolean;
+ consentChecked: boolean;
+ needsConsent: boolean;
+ onConsentRoute: boolean;
+ onConsentReviewRoute: boolean;
+};
+
+export type BootstrapDecision = {
+ tag: BootstrapDecisionTag;
+ hasUserBootstrapError: boolean;
+ hasConsentBootstrapError: boolean;
+ hasBootstrapError: boolean;
+ hidden: boolean;
+};
+
+// Order mirrors the auth effect's if-chain exactly: earlier branches win.
+function resolveBootstrapTag(input: BootstrapDecisionInput): BootstrapDecisionTag {
+ if (input.isLoading) {
+ return 'wait-loading';
+ }
+ if (input.updateRequired) {
+ return input.inForceUpdate ? 'settle-force-update' : 'redirect-force-update';
+ }
+ if (input.inForceUpdate) {
+ return 'exit-force-update';
+ }
+ if (!input.hasToken) {
+ return input.inAuthGroup ? 'settle-login' : 'redirect-login';
+ }
+ if (input.userIdError) {
+ return 'settle-user-error';
+ }
+ if (input.consentCheckError) {
+ return 'settle-consent-error';
+ }
+ if (input.userIdLoading || !input.consentChecked) {
+ return 'wait-user-consent';
+ }
+ if (input.needsConsent) {
+ return input.onConsentRoute ? 'settle-consent' : 'redirect-consent';
+ }
+ if ((input.onConsentRoute && !input.onConsentReviewRoute) || input.inAuthGroup) {
+ return 'redirect-app';
+ }
+ return 'settle-app';
+}
+
+export function resolveBootstrapDecision(input: BootstrapDecisionInput): BootstrapDecision {
+ const hasUserBootstrapError = input.hasToken && input.userIdError;
+ const hasConsentBootstrapError = input.hasToken && input.consentCheckError;
+ const hasBootstrapError = hasUserBootstrapError || hasConsentBootstrapError;
+ const consentLoading =
+ input.hasToken &&
+ !input.consentChecked &&
+ !input.inAuthGroup &&
+ !input.inForceUpdate &&
+ !input.onConsentRoute;
+ const needsForceUpdate = input.updateRequired && !input.inForceUpdate;
+ const showingForceUpdate = input.updateRequired && input.inForceUpdate;
+ const needsAuth = !input.hasToken && !input.inAuthGroup;
+ const needsAppRedirect = input.hasToken && input.inAuthGroup;
+ const needsConsentRedirect = input.consentChecked && input.needsConsent && !input.onConsentRoute;
+ const needsRedirect =
+ !input.isLoading &&
+ (needsForceUpdate ||
+ (!showingForceUpdate && (needsAuth || needsAppRedirect || needsConsentRedirect)));
+ const hidden =
+ !hasUserBootstrapError &&
+ !hasConsentBootstrapError &&
+ (input.isLoading || needsRedirect || consentLoading);
+
+ return {
+ tag: resolveBootstrapTag(input),
+ hasUserBootstrapError,
+ hasConsentBootstrapError,
+ hasBootstrapError,
+ hidden,
+ };
+}
diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts
index 3c41c0f838..b4f77ba125 100644
--- a/apps/mobile/src/lib/config.ts
+++ b/apps/mobile/src/lib/config.ts
@@ -1,5 +1,11 @@
import expoConstants from 'expo-constants';
import { type ENV_KEYS, type OPTIONAL_ENV_KEYS } from './env-keys';
+import {
+ assertProductionHost,
+ assertUrlScheme,
+ PRODUCTION_HOSTS,
+ URL_SCHEMES,
+} from '@/lib/url-contract';
const extra = expoConstants.expoConfig?.extra;
@@ -37,6 +43,21 @@ export const PLAY_INTEGRITY_PROJECT_NUMBER: string | undefined = optional(
);
export const SENTRY_ENVIRONMENT: string | undefined = optional('sentryEnvironment');
+// URL contract at module evaluation. The production host check keys off the
+// baked `extra.isProductionBuild` flag, not the Sentry environment, so a
+// preview release build never crashes on preview hosts. The `required`
+// presence check above is the old presence-only check; remove it when every
+// build passes through the config boundary in app.config.ts, which already
+// throws on missing values.
+const runProductionHostCheck = !__DEV__ && extra?.isProductionBuild === true;
+for (const [key, schemes] of Object.entries(URL_SCHEMES)) {
+ const value = required(key as keyof typeof ENV_KEYS);
+ assertUrlScheme(key, value, schemes, { allowInsecure: __DEV__ });
+ if (runProductionHostCheck) {
+ assertProductionHost(key, value, PRODUCTION_HOSTS);
+ }
+}
+
function optionalLatencyMs(key: keyof typeof OPTIONAL_ENV_KEYS): number {
const parsed = Number.parseInt(optional(key) ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
diff --git a/apps/mobile/src/lib/hooks/use-after-interactions.ts b/apps/mobile/src/lib/hooks/use-after-interactions.ts
new file mode 100644
index 0000000000..f9e1c08b58
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-after-interactions.ts
@@ -0,0 +1,25 @@
+import { useEffect, useState } from 'react';
+import { InteractionManager } from 'react-native';
+
+/**
+ * Defer mount-time work until the current interaction frame settles.
+ *
+ * Returns false on mount, then true once `InteractionManager` runs the
+ * callback after the navigation transition finishes. The handle is cancelled
+ * on unmount so a late callback never sets state on an unmounted component.
+ */
+export function useAfterInteractions(): boolean {
+ const [afterInteractions, setAfterInteractions] = useState(false);
+
+ useEffect(() => {
+ // eslint-disable-next-line typescript-eslint/no-deprecated -- InteractionManager.runAfterInteractions is the documented API for deferring work past the current interaction frame.
+ const handle = InteractionManager.runAfterInteractions(() => {
+ setAfterInteractions(true);
+ });
+ return () => {
+ handle.cancel();
+ };
+ }, []);
+
+ return afterInteractions;
+}
diff --git a/apps/mobile/src/lib/startup-order.test.ts b/apps/mobile/src/lib/startup-order.test.ts
new file mode 100644
index 0000000000..6f0aa860a6
--- /dev/null
+++ b/apps/mobile/src/lib/startup-order.test.ts
@@ -0,0 +1,49 @@
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app
+import { readFileSync } from 'node:fs';
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+// Text-contract guard over the root layout source. The module-scope bootstrap
+// kicks (Sentry init, notification wiring, prefetch, theme preload) must stay
+// in _layout.tsx, and the deferred connections/analytics must stay out of it.
+const layoutPath = fileURLToPath(new URL('../app/_layout.tsx', import.meta.url));
+const layoutSource = readFileSync(layoutPath, 'utf8');
+
+const FORBIDDEN_IDENTIFIERS = [
+ 'createUserWebConnection',
+ 'EventServiceClient',
+ 'useIAP',
+ 'initPostHog',
+ 'initAppsFlyer',
+ 'new WebSocket',
+] as const;
+
+// Removes `//` line comments and `/* */` block comments while preserving line
+// breaks, so a comment mentioning the call cannot satisfy the module-scope
+// assertion.
+function stripComments(source: string): string {
+ return source
+ .replaceAll(/\/\*[\s\S]*?\*\//g, match => match.replaceAll(/[^\n]/g, ''))
+ .replaceAll(/\/\/[^\n]*/g, '');
+}
+
+describe('root layout startup order (text contract)', () => {
+ it('calls initSentry(false) at module scope', () => {
+ const codeSource = stripComments(layoutSource);
+ const hasModuleScopeCall = codeSource
+ .split('\n')
+ .some(line => /^initSentry\(false\);$/.test(line));
+ expect(
+ hasModuleScopeCall,
+ '_layout.tsx must call initSentry(false); at module scope (column 0)'
+ ).toBe(true);
+ });
+
+ it.each(FORBIDDEN_IDENTIFIERS)('does not reference %s', identifier => {
+ expect(layoutSource.includes(identifier), `_layout.tsx must not contain "${identifier}"`).toBe(
+ false
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/url-contract.js b/apps/mobile/src/lib/url-contract.js
new file mode 100644
index 0000000000..c9e5324621
--- /dev/null
+++ b/apps/mobile/src/lib/url-contract.js
@@ -0,0 +1,86 @@
+/** URL scheme and production-host contract for mobile config values.
+ * Plain .js because the Expo config loader cannot consume workspace TS
+ * (same reason env-keys.js and sentry-dsn.js exist). Metro and vitest
+ * import .js fine, so the same file serves the config boundary
+ * (app.config.ts), the runtime boundary (config.ts), and the unit tests. */
+
+/** Config key → allowed URL schemes. URL keys only — appsFlyerDevKey,
+ * appsFlyerAppId, and posthogApiKey are not URLs and get no scheme check. */
+export const URL_SCHEMES = {
+ apiBaseUrl: ['https:'],
+ webBaseUrl: ['https:'],
+ kiloChatUrl: ['https:'],
+ notificationsUrl: ['https:'],
+ cloudAgentWsUrl: ['wss:'],
+ sessionIngestWsUrl: ['wss:'],
+ // The event-service client accepts both https: and wss:
+ // (packages/event-service/src/client.ts:38-49).
+ eventServiceUrl: ['https:', 'wss:'],
+};
+
+/** Production host allowlist, seeded from the committed apps/mobile/.env
+ * production defaults. The .env URL values include api.kilo.ai, app.kilo.ai,
+ * cloud-agent-next.kilosessions.ai, ingest.kilosessions.ai, chat.kiloapps.io,
+ * events.kiloapps.io, and notifications.kiloapps.io. url-contract.test.ts
+ * asserts every committed .env URL value against this list, so a missing
+ * host fails the test before any build. Preflight is the runtime safety net:
+ * the release preflight runs assertProductionHost against the real production
+ * values, so an incomplete allowlist fails preflight before any build, never
+ * at runtime in a store build. */
+export const PRODUCTION_HOSTS = [
+ 'api.kilo.ai',
+ 'app.kilo.ai',
+ 'chat.kiloapps.io',
+ 'cloud-agent-next.kilosessions.ai',
+ 'events.kiloapps.io',
+ 'ingest.kilosessions.ai',
+ 'notifications.kiloapps.io',
+];
+
+/** Parse a URL value, throwing a clear error for a missing or malformed URL.
+ * @param {string} name
+ * @param {string | undefined} value
+ * @returns {URL}
+ */
+function parseUrl(name, value) {
+ if (!value) {
+ throw new Error(`Missing URL for ${name}`);
+ }
+ try {
+ return new URL(value);
+ } catch {
+ throw new Error(`Invalid URL for ${name}: ${value}`);
+ }
+}
+
+/** Throws unless the URL scheme is in `schemes`. `allowInsecure` additionally
+ * permits http: and ws: for local development.
+ * @param {string} name
+ * @param {string | undefined} value
+ * @param {string[]} schemes
+ * @param {{ allowInsecure?: boolean }} [options]
+ */
+// oxlint-disable-next-line max-params -- the options object carries the allowInsecure flag per the URL contract
+export function assertUrlScheme(name, value, schemes, { allowInsecure = false } = {}) {
+ const parsed = parseUrl(name, value);
+ const allowed = allowInsecure ? [...schemes, 'http:', 'ws:'] : schemes;
+ if (!allowed.includes(parsed.protocol)) {
+ throw new Error(
+ `Invalid scheme for ${name}: expected ${schemes.join(' or ')}, got ${parsed.protocol}`
+ );
+ }
+}
+
+/** Throws when the URL host is outside the allowlist.
+ * @param {string} name
+ * @param {string | undefined} url
+ * @param {string[]} allowedHosts
+ */
+export function assertProductionHost(name, url, allowedHosts) {
+ const parsed = parseUrl(name, url);
+ if (!allowedHosts.includes(parsed.hostname)) {
+ throw new Error(
+ `Production host for ${name} (${parsed.hostname}) is outside the allowlist: ${allowedHosts.join(', ')}`
+ );
+ }
+}
diff --git a/apps/mobile/src/lib/url-contract.test.ts b/apps/mobile/src/lib/url-contract.test.ts
new file mode 100644
index 0000000000..5af6ac6af2
--- /dev/null
+++ b/apps/mobile/src/lib/url-contract.test.ts
@@ -0,0 +1,222 @@
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app
+import { readFileSync } from 'node:fs';
+// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app
+import { fileURLToPath } from 'node:url';
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ assertProductionHost,
+ assertUrlScheme,
+ PRODUCTION_HOSTS,
+ URL_SCHEMES,
+} from '@/lib/url-contract';
+import { ENV_KEYS } from './env-keys';
+
+describe('assertUrlScheme', () => {
+ const productionCases: { key: keyof typeof URL_SCHEMES; value: string }[] = [
+ { key: 'apiBaseUrl', value: 'https://api.kilo.ai' },
+ { key: 'webBaseUrl', value: 'https://app.kilo.ai' },
+ { key: 'kiloChatUrl', value: 'https://chat.kilo.ai' },
+ { key: 'notificationsUrl', value: 'https://notifications.kilo.ai' },
+ { key: 'cloudAgentWsUrl', value: 'wss://cloud-agent.kilo.ai' },
+ { key: 'sessionIngestWsUrl', value: 'wss://session-ingest.kilo.ai' },
+ { key: 'eventServiceUrl', value: 'https://events.kilo.ai' },
+ ];
+
+ it('accepts each URL key with its production scheme', () => {
+ for (const { key, value } of productionCases) {
+ expect(() => {
+ assertUrlScheme(key, value, URL_SCHEMES[key], { allowInsecure: false });
+ }).not.toThrow();
+ }
+ });
+
+ it('rejects a wrong scheme per key', () => {
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', 'http://api.kilo.ai', URL_SCHEMES.apiBaseUrl, {
+ allowInsecure: false,
+ });
+ }).toThrow(/scheme/);
+ expect(() => {
+ assertUrlScheme(
+ 'cloudAgentWsUrl',
+ 'https://cloud-agent.kilo.ai',
+ URL_SCHEMES.cloudAgentWsUrl,
+ {
+ allowInsecure: false,
+ }
+ );
+ }).toThrow(/scheme/);
+ });
+
+ it('permits http: and ws: when allowInsecure is true', () => {
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', 'http://localhost:3000', URL_SCHEMES.apiBaseUrl, {
+ allowInsecure: true,
+ });
+ }).not.toThrow();
+ expect(() => {
+ assertUrlScheme('cloudAgentWsUrl', 'ws://localhost:8080', URL_SCHEMES.cloudAgentWsUrl, {
+ allowInsecure: true,
+ });
+ }).not.toThrow();
+ });
+
+ it('still rejects http: and ws: when allowInsecure is false', () => {
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', 'http://api.kilo.ai', URL_SCHEMES.apiBaseUrl, {
+ allowInsecure: false,
+ });
+ }).toThrow(/scheme/);
+ expect(() => {
+ assertUrlScheme('cloudAgentWsUrl', 'ws://cloud-agent.kilo.ai', URL_SCHEMES.cloudAgentWsUrl, {
+ allowInsecure: false,
+ });
+ }).toThrow(/scheme/);
+ });
+
+ it('rejects an unparseable URL', () => {
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', 'not a url', URL_SCHEMES.apiBaseUrl, { allowInsecure: false });
+ }).toThrow(/Invalid URL/);
+ });
+
+ it('throws a clear error for a missing value', () => {
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', undefined, URL_SCHEMES.apiBaseUrl, { allowInsecure: false });
+ }).toThrow(/Missing URL for apiBaseUrl/);
+ expect(() => {
+ assertUrlScheme('apiBaseUrl', '', URL_SCHEMES.apiBaseUrl, { allowInsecure: false });
+ }).toThrow(/Missing URL for apiBaseUrl/);
+ });
+
+ it('eventServiceUrl accepts both https: and wss:', () => {
+ expect(() => {
+ assertUrlScheme('eventServiceUrl', 'https://events.kilo.ai', URL_SCHEMES.eventServiceUrl, {
+ allowInsecure: false,
+ });
+ }).not.toThrow();
+ expect(() => {
+ assertUrlScheme('eventServiceUrl', 'wss://events.kilo.ai', URL_SCHEMES.eventServiceUrl, {
+ allowInsecure: false,
+ });
+ }).not.toThrow();
+ expect(() => {
+ assertUrlScheme('eventServiceUrl', 'http://events.kilo.ai', URL_SCHEMES.eventServiceUrl, {
+ allowInsecure: false,
+ });
+ }).toThrow(/scheme/);
+ });
+});
+
+describe('assertProductionHost', () => {
+ it('accepts a host in the allowlist', () => {
+ expect(() => {
+ assertProductionHost('apiBaseUrl', 'https://api.kilo.ai', PRODUCTION_HOSTS);
+ }).not.toThrow();
+ expect(() => {
+ assertProductionHost('webBaseUrl', 'https://app.kilo.ai', PRODUCTION_HOSTS);
+ }).not.toThrow();
+ });
+
+ it('rejects a host outside the allowlist', () => {
+ expect(() => {
+ assertProductionHost('apiBaseUrl', 'https://evil.example.com', PRODUCTION_HOSTS);
+ }).toThrow(/outside the allowlist/);
+ });
+
+ it('rejects an unparseable URL', () => {
+ expect(() => {
+ assertProductionHost('apiBaseUrl', 'not a url', PRODUCTION_HOSTS);
+ }).toThrow(/Invalid URL/);
+ });
+
+ it('throws a clear error for a missing value', () => {
+ expect(() => {
+ assertProductionHost('apiBaseUrl', undefined, PRODUCTION_HOSTS);
+ }).toThrow(/Missing URL for apiBaseUrl/);
+ expect(() => {
+ assertProductionHost('apiBaseUrl', '', PRODUCTION_HOSTS);
+ }).toThrow(/Missing URL for apiBaseUrl/);
+ });
+});
+
+// Host-contract guard over the committed apps/mobile/.env production defaults.
+// Every URL value must pass the scheme check and stay inside the allowlist, so
+// a missing host fails the test before any build.
+const envPath = fileURLToPath(new URL('../../.env', import.meta.url));
+const envSource = readFileSync(envPath, 'utf8');
+
+function parseEnv(source: string): Record {
+ const entries: Record = {};
+ for (const rawLine of source.split('\n')) {
+ const match = /^([A-Z0-9_]+)=(.*)$/.exec(rawLine.trim());
+ const name = match?.[1];
+ const value = match?.[2];
+ if (name !== undefined && value !== undefined) {
+ entries[name] = value;
+ }
+ }
+ return entries;
+}
+
+const committedEnv = parseEnv(envSource);
+const urlKeys = Object.keys(URL_SCHEMES) as (keyof typeof URL_SCHEMES)[];
+
+describe('committed .env production defaults (host contract)', () => {
+ it('accepts every committed URL value for scheme and production host', () => {
+ for (const key of urlKeys) {
+ const envVar = ENV_KEYS[key];
+ const value = committedEnv[envVar];
+ expect(value, `${envVar} must be present in the committed .env`).toBeTruthy();
+ expect(() => {
+ assertUrlScheme(key, value, URL_SCHEMES[key], { allowInsecure: false });
+ }).not.toThrow();
+ expect(() => {
+ assertProductionHost(key, value, PRODUCTION_HOSTS);
+ }).not.toThrow();
+ }
+ });
+});
+
+// Text-contract guard over app.config.ts. The URL-contract loop must skip
+// absent values (warn-under-CI path), and the production path must keep its
+// fatal-by-intent throw and Sentry source-map gate.
+const configPath = fileURLToPath(new URL('../../app.config.ts', import.meta.url));
+const configSource = readFileSync(configPath, 'utf8');
+const configTsPath = fileURLToPath(new URL('config.ts', import.meta.url));
+const configTsSource = readFileSync(configTsPath, 'utf8');
+
+// Removes `//` line comments and `/* */` block comments while preserving line
+// breaks, so a comment mentioning a gate cannot satisfy the assertion.
+function stripComments(source: string): string {
+ return source
+ .replaceAll(/\/\*[\s\S]*?\*\//g, match => match.replaceAll(/[^\n]/g, ''))
+ .replaceAll(/\/\/[^\n]*/g, '');
+}
+
+const configCodeSource = stripComments(configSource);
+const configTsCodeSource = stripComments(configTsSource);
+
+describe('app.config.ts config boundary (text contract)', () => {
+ it('skips absent URL values instead of asserting them', () => {
+ expect(configCodeSource).toMatch(/if \(!value\)\s*continue/);
+ });
+
+ it('keeps the fatal-by-intent production gate', () => {
+ expect(configCodeSource).toMatch(/EAS_BUILD_PROFILE === 'production'/);
+ });
+
+ it('keeps the Sentry source-map upload gate', () => {
+ expect(configCodeSource).toMatch(/SENTRY_AUTH_TOKEN/);
+ });
+
+ it('bakes isProductionBuild into the extra block', () => {
+ expect(configCodeSource).toMatch(/extra:\s*\{[\s\S]*?isProductionBuild,/);
+ });
+
+ it('gates the runtime production host check on the baked flag', () => {
+ expect(configTsCodeSource).toContain('extra?.isProductionBuild === true');
+ });
+});
diff --git a/scripts/inspect-mobile-artifacts.mjs b/scripts/inspect-mobile-artifacts.mjs
new file mode 100644
index 0000000000..bfeee53e6e
--- /dev/null
+++ b/scripts/inspect-mobile-artifacts.mjs
@@ -0,0 +1,289 @@
+#!/usr/bin/env node
+/**
+ * Inspect signed kilo-app mobile artifacts before submission.
+ *
+ * Usage:
+ * node scripts/inspect-mobile-artifacts.mjs
+ * node scripts/inspect-mobile-artifacts.mjs --select
+ *
+ * The full mode unzips the IPA, parses its Info.plist, dumps the AAB manifest
+ * with bundletool, and checks debug symbols. The --select mode validates the
+ * EAS build.json (every build FINISHED, one IOS and one ANDROID entry with an
+ * applicationArchiveUrl) and prints the two archive URLs, one per line.
+ *
+ * Exits 1 with a clear message on any contract violation.
+ */
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const BUNDLE_IDENTIFIER = 'com.kilocode.kiloapp';
+const ANDROID_PACKAGE = 'com.kilocode.kiloapp';
+const SKADNETWORK_ENDPOINT = 'https://appsflyer-skadnetwork.com/';
+const INTENT_FILTER_HOST = 'app.kilo.ai';
+const BUNDLETOOL_URL =
+ 'https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar';
+const DEBUGSYMBOLS_PREFIX = 'BUNDLE-METADATA/com.android.tools.build.debugsymbols/';
+const REQUIRED_USAGE_DESCRIPTIONS = [
+ 'NSMicrophoneUsageDescription',
+ 'NSSpeechRecognitionUsageDescription',
+ 'NSLocationWhenInUseUsageDescription',
+ 'NSUserTrackingUsageDescription',
+];
+const BLOCKED_PERMISSIONS = [
+ 'android.permission.READ_MEDIA_IMAGES',
+ 'android.permission.READ_MEDIA_VIDEO',
+ 'android.permission.READ_MEDIA_AUDIO',
+];
+
+const failures = [];
+
+function check(condition, message) {
+ if (!condition) {
+ failures.push(message);
+ }
+}
+
+function run(cmd, args) {
+ return execFileSync(cmd, args, {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'inherit'],
+ });
+}
+
+function reportAndExit() {
+ if (failures.length === 0) {
+ process.exit(0);
+ }
+ console.error('Mobile artifact inspection failed:');
+ for (const failure of failures) {
+ console.error(` - ${failure}`);
+ }
+ process.exit(1);
+}
+
+function parseBuildJson(path) {
+ let raw;
+ try {
+ raw = readFileSync(path, 'utf8');
+ } catch (error) {
+ failures.push(`cannot read build.json: ${error.message}`);
+ reportAndExit();
+ }
+ let builds;
+ try {
+ builds = JSON.parse(raw);
+ } catch (error) {
+ failures.push(`build.json is not valid JSON: ${error.message}`);
+ reportAndExit();
+ }
+ if (!Array.isArray(builds)) {
+ failures.push('build.json must be a JSON array of build objects');
+ reportAndExit();
+ }
+ return builds;
+}
+
+function assertAllFinished(builds) {
+ const unfinished = builds.filter(build => build && build.status !== 'FINISHED');
+ if (unfinished.length > 0) {
+ const detail = unfinished
+ .map(build => `${build.platform ?? 'unknown'}=${build.status ?? 'missing'}`)
+ .join(', ');
+ failures.push(`every EAS build must be FINISHED, got: ${detail}`);
+ }
+}
+
+function selectBuild(builds, platform) {
+ return builds.find(build => build && build.platform === platform);
+}
+
+function artifactUrl(build) {
+ return build?.artifacts?.applicationArchiveUrl ?? '';
+}
+
+function selectMode(buildJsonPath) {
+ const builds = parseBuildJson(buildJsonPath);
+ assertAllFinished(builds);
+ const ios = selectBuild(builds, 'IOS');
+ const android = selectBuild(builds, 'ANDROID');
+ if (!ios) {
+ failures.push('build.json has no IOS build');
+ }
+ if (!android) {
+ failures.push('build.json has no ANDROID build');
+ }
+ const iosUrl = artifactUrl(ios);
+ const androidUrl = artifactUrl(android);
+ if (!iosUrl) {
+ failures.push('IOS build has no artifacts.applicationArchiveUrl');
+ }
+ if (!androidUrl) {
+ failures.push('ANDROID build has no artifacts.applicationArchiveUrl');
+ }
+ if (failures.length > 0) {
+ reportAndExit();
+ }
+ process.stdout.write(`${iosUrl}\n${androidUrl}\n`);
+ process.exit(0);
+}
+
+function parseInfoPlist(plistPath) {
+ // A signed IPA's Info.plist is binary. Python's plistlib stdlib handles both
+ // XML and binary formats and is preinstalled on the ubuntu-latest runner.
+ const script = [
+ 'import plistlib, json, sys',
+ 'with open(sys.argv[1], "rb") as f:',
+ ' data = plistlib.load(f)',
+ 'json.dump(data, sys.stdout)',
+ ].join('\n');
+ const out = run('python3', ['-c', script, plistPath]);
+ return JSON.parse(out);
+}
+
+function inspectIos(ipaPath) {
+ const work = mkdtempSync(join(tmpdir(), 'kilo-inspect-ios-'));
+ try {
+ const extractDir = join(work, 'ipa');
+ mkdirSync(extractDir, { recursive: true });
+ try {
+ run('unzip', ['-q', '-o', ipaPath, '-d', extractDir]);
+ } catch (error) {
+ failures.push(`cannot unzip IPA ${ipaPath}: ${error.message}`);
+ return;
+ }
+
+ const payloadDir = join(extractDir, 'Payload');
+ let appName;
+ try {
+ appName = readdirSync(payloadDir).find(entry => entry.endsWith('.app'));
+ } catch {
+ appName = undefined;
+ }
+ if (!appName) {
+ failures.push(`IPA has no Payload/*.app bundle (checked ${payloadDir})`);
+ return;
+ }
+ const appPath = join(payloadDir, appName);
+
+ let plist;
+ try {
+ plist = parseInfoPlist(join(appPath, 'Info.plist'));
+ } catch (error) {
+ failures.push(`cannot parse Info.plist: ${error.message}`);
+ return;
+ }
+
+ check(
+ plist.CFBundleIdentifier === BUNDLE_IDENTIFIER,
+ `CFBundleIdentifier must be "${BUNDLE_IDENTIFIER}", got "${plist.CFBundleIdentifier}"`
+ );
+ check(
+ existsSync(join(appPath, 'PrivacyInfo.xcprivacy')),
+ 'PrivacyInfo.xcprivacy must exist in the .app bundle'
+ );
+ for (const key of REQUIRED_USAGE_DESCRIPTIONS) {
+ check(
+ typeof plist[key] === 'string' && plist[key].length > 0,
+ `Info.plist must contain a non-empty ${key}`
+ );
+ }
+ check(
+ plist.NSAdvertisingAttributionReportEndpoint === SKADNETWORK_ENDPOINT,
+ `NSAdvertisingAttributionReportEndpoint must be "${SKADNETWORK_ENDPOINT}"`
+ );
+ check(
+ plist.AttributionCopyEndpoint === SKADNETWORK_ENDPOINT,
+ `AttributionCopyEndpoint must be "${SKADNETWORK_ENDPOINT}"`
+ );
+ } finally {
+ rmSync(work, { recursive: true, force: true });
+ }
+}
+
+function listZipEntries(zipPath) {
+ try {
+ return run('unzip', ['-Z1', zipPath])
+ .split('\n')
+ .filter(entry => entry.length > 0);
+ } catch (error) {
+ failures.push(`cannot list zip entries of ${zipPath}: ${error.message}`);
+ return [];
+ }
+}
+
+function inspectAndroid(aabPath) {
+ const work = mkdtempSync(join(tmpdir(), 'kilo-inspect-android-'));
+ try {
+ const jarPath = join(work, 'bundletool.jar');
+ try {
+ run('curl', ['-fsSL', BUNDLETOOL_URL, '-o', jarPath]);
+ } catch (error) {
+ failures.push(`cannot download bundletool: ${error.message}`);
+ return;
+ }
+
+ let manifest;
+ try {
+ manifest = run('java', ['-jar', jarPath, 'dump', 'manifest', '--bundle', aabPath]);
+ } catch (error) {
+ failures.push(`bundletool dump manifest failed: ${error.message}`);
+ return;
+ }
+
+ const packageMatch = manifest.match(/package="([^"]+)"/);
+ check(
+ packageMatch?.[1] === ANDROID_PACKAGE,
+ `android package must be "${ANDROID_PACKAGE}", got "${packageMatch?.[1] ?? 'none'}"`
+ );
+ for (const permission of BLOCKED_PERMISSIONS) {
+ check(
+ !manifest.includes(`android:name="${permission}"`),
+ `${permission} must be absent from the manifest`
+ );
+ }
+ check(
+ !manifest.includes('usesCleartextTraffic="true"'),
+ 'usesCleartextTraffic="true" must be absent from the manifest'
+ );
+ check(
+ manifest.includes(`android:host="${INTENT_FILTER_HOST}"`),
+ `intent-filter host must be "${INTENT_FILTER_HOST}"`
+ );
+ } finally {
+ rmSync(work, { recursive: true, force: true });
+ }
+}
+
+function checkSymbols(aabPath) {
+ const entries = listZipEntries(aabPath);
+ const aabHasDebugSymbols = entries.some(entry => entry.startsWith(DEBUGSYMBOLS_PREFIX));
+ check(aabHasDebugSymbols, `no debug symbols: the AAB has no ${DEBUGSYMBOLS_PREFIX} entries`);
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ if (args[0] === '--select') {
+ if (args.length !== 2) {
+ console.error('Usage: node inspect-mobile-artifacts.mjs --select ');
+ process.exit(2);
+ }
+ selectMode(args[1]);
+ return;
+ }
+ if (args.length !== 3) {
+ console.error('Usage: node inspect-mobile-artifacts.mjs ');
+ console.error(' node inspect-mobile-artifacts.mjs --select ');
+ process.exit(2);
+ }
+ const [ipaPath, aabPath, buildJsonPath] = args;
+ const builds = parseBuildJson(buildJsonPath);
+ assertAllFinished(builds);
+ inspectIos(ipaPath);
+ inspectAndroid(aabPath);
+ checkSymbols(aabPath);
+ reportAndExit();
+}
+
+main();