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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExpoConfig } from 'expo/config';
import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys';
import { UNIVERSAL_LINK_PATH_PATTERNS } from './src/lib/universal-link-paths';

const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]);
if (missing.length > 0) {
Expand Down Expand Up @@ -36,6 +37,7 @@ const config: ExpoConfig = {
requireFullScreen: true,
supportsTablet: true,
usesAppleSignIn: true,
associatedDomains: ['applinks:app.kilo.ai'],
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
NSAdvertisingAttributionReportEndpoint: 'https://appsflyer-skadnetwork.com/',
Expand Down Expand Up @@ -71,6 +73,18 @@ const config: ExpoConfig = {
'android.permission.READ_MEDIA_VIDEO',
'android.permission.READ_MEDIA_AUDIO',
],
intentFilters: [
{
action: 'VIEW',
autoVerify: true,
data: UNIVERSAL_LINK_PATH_PATTERNS.map(pathPattern => ({
scheme: 'https',
host: 'app.kilo.ai',
pathPattern,
})),
category: ['BROWSABLE', 'DEFAULT'],
},
],
},
plugins: [
['expo-dev-client', { toolsButton: false }],
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/app/+native-intent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { getShareExtensionKey } from 'expo-share-intent';

import { redirectSystemPath as mapWebPath } from '@/lib/deep-link-handler';

// Composes both native-intent concerns: the share-extension check must return
// early before web-path mapping (a share URL is never a web route).
export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }) {
let shareKey: string | null = null;
try {
Expand All @@ -11,5 +15,5 @@ export function redirectSystemPath({ path, initial }: { path: string; initial: b
// Cold start: boot the app normally. Warm: stay exactly where the user is.
return initial ? '/' : null;
}
return path;
return mapWebPath({ path, initial });
}
9 changes: 5 additions & 4 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ import { useScreenTracking } from '@/lib/hooks/use-screen-tracking';
import { useNavigationTheme } from '@/lib/hooks/use-theme-colors';
import { applyThemePreference, useThemePreference } from '@/lib/hooks/use-theme-preference';
import { useTrackingPermissionPrompt } from '@/lib/hooks/use-tracking-permission-prompt';
import { captureLaunchDeepLink, getPendingDeepLink } from '@/lib/deep-link-launch';
import {
checkInitialNotification,
getPendingNotificationLink,
setupNotificationHandler,
setupNotificationResponseHandler,
} from '@/lib/notifications';
import { resolvePendingNotificationNavigation } from '@/lib/pending-notification-navigation';
import { resolvePendingNavigation } from '@/lib/pending-navigation';
import {
isShellReadyForShare,
resolvePendingShareNavigation,
Expand Down Expand Up @@ -98,6 +98,7 @@ initSentry(false);
void SplashScreen.preventAutoHideAsync();
setupNotificationHandler();
checkInitialNotification();
captureLaunchDeepLink();

function RootLayoutNav() {
const { token, isLoading: authLoading, signOut } = useAuth();
Expand Down Expand Up @@ -341,8 +342,8 @@ function RootLayoutNav() {
}

void SplashScreen.hideAsync();
// Navigate to pending notification deep link (cold start / background tap)
const pendingNavigation = resolvePendingNotificationNavigation(getPendingNotificationLink());
// Navigate to pending deep link (cold start universal link / notification tap)
const pendingNavigation = resolvePendingNavigation(getPendingDeepLink());
Comment thread
iscekic marked this conversation as resolved.
if (pendingNavigation) {
router.navigate(pendingNavigation.href as Href);
}
Expand Down
167 changes: 167 additions & 0 deletions apps/mobile/src/lib/deep-link-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type * as UniversalLinks from '@kilocode/app-shared/universal-links';

import { redirectSystemPath } from './deep-link-handler';
import {
_resetDeepLinkLaunchForTests,
_setGetLinkingURLForTests,
captureLaunchDeepLink,
getPendingDeepLink,
} from './deep-link-launch';

const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
shouldThrow: false,
}));

vi.mock('expo-router', () => ({
router: {
navigate: mocks.navigate,
},
}));

vi.mock('@kilocode/app-shared/universal-links', async importOriginal => {
const actual = await importOriginal<typeof UniversalLinks>();
return {
...actual,
resolveIncomingUrl: (raw: string) => {
if (mocks.shouldThrow) {
throw new Error('boom');
}
return actual.resolveIncomingUrl(raw);
},
};
});

const MAPPED_CASES = [
{
path: 'https://app.kilo.ai/profile',
href: '/(app)/(tabs)/(3_profile)',
},
{
path: 'https://app.kilo.ai/security-agent/findings',
href: '/(app)/(tabs)/(3_profile)/security-agent/personal/findings',
},
{
path: 'https://app.kilo.ai/code-reviews/rev_9',
href: '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews/rev_9',
},
] as const;

describe('redirectSystemPath', () => {
beforeEach(() => {
_resetDeepLinkLaunchForTests();
mocks.navigate.mockReset();
mocks.shouldThrow = false;
});

afterEach(() => {
_resetDeepLinkLaunchForTests();
mocks.shouldThrow = false;
});

describe('cold invariant', () => {
it.each(MAPPED_CASES)('stashes $path and does not navigate', ({ path, href }) => {
const result = redirectSystemPath({ path, initial: true });
expect(result).toBeNull();
expect(!result).toBe(true);
expect(getPendingDeepLink()).toBe(href);
expect(mocks.navigate).not.toHaveBeenCalled();
});
});

describe('warm invariant', () => {
it.each(MAPPED_CASES)('navigates $path and leaves pending empty', ({ path, href }) => {
const result = redirectSystemPath({ path, initial: false });
expect(result).toBeNull();
expect(!result).toBe(true);
expect(mocks.navigate).toHaveBeenCalledOnce();
expect(mocks.navigate).toHaveBeenCalledWith(href);
expect(getPendingDeepLink()).toBeNull();
});
});

describe('passthrough', () => {
it.each([
'https://app.kilo.ai/admin',
'https://app.kilo.ai/code-reviews/review-md',
'https://example.com/profile',
'not a url',
])('returns %s unchanged without navigate or stash', path => {
const result = redirectSystemPath({ path, initial: true });
expect(result).toBe(path);
expect(getPendingDeepLink()).toBeNull();
expect(mocks.navigate).not.toHaveBeenCalled();

const warm = redirectSystemPath({ path, initial: false });
expect(warm).toBe(path);
expect(getPendingDeepLink()).toBeNull();
expect(mocks.navigate).not.toHaveBeenCalled();
});
});

describe('kiloapp:// forms', () => {
it('cold kiloapp:///profile stashes group href and returns null', () => {
const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true });
expect(result).toBeNull();
expect(!result).toBe(true);
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)');
expect(mocks.navigate).not.toHaveBeenCalled();
});

it('warm kiloapp://profile navigates group href and returns null', () => {
const result = redirectSystemPath({ path: 'kiloapp://profile', initial: false });
expect(result).toBeNull();
expect(!result).toBe(true);
expect(mocks.navigate).toHaveBeenCalledOnce();
expect(mocks.navigate).toHaveBeenCalledWith('/(app)/(tabs)/(3_profile)');
expect(getPendingDeepLink()).toBeNull();
});
});

describe('launch-capture dedup', () => {
it('cold initial does not restash when the launch capture already stashed the link', () => {
_setGetLinkingURLForTests(() => 'kiloapp:///profile');
captureLaunchDeepLink();
// The gate effect can consume the slot before expo-router's cold path resolves.
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)');
const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true });
expect(result).toBeNull();
// No restash — a later, unrelated effect re-run must find the slot empty.
expect(getPendingDeepLink()).toBeNull();
expect(mocks.navigate).not.toHaveBeenCalled();
});

it('cold initial still stashes when the launch capture found no link', () => {
_setGetLinkingURLForTests(() => null);
captureLaunchDeepLink();
const result = redirectSystemPath({ path: 'kiloapp:///profile', initial: true });
expect(result).toBeNull();
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)');
expect(mocks.navigate).not.toHaveBeenCalled();
});
});

describe('synchronicity', () => {
it('return value is not a Promise', () => {
const result = redirectSystemPath({
path: 'https://app.kilo.ai/profile',
initial: true,
});
expect(result).not.toBeInstanceOf(Promise);
expect(result).toBeNull();
});
});

describe('try/catch', () => {
it('returns path unchanged when resolveIncomingUrl throws', () => {
mocks.shouldThrow = true;
const path = 'https://app.kilo.ai/profile';
const result = redirectSystemPath({ path, initial: true });
expect(result).toBe(path);
expect(getPendingDeepLink()).toBeNull();
expect(mocks.navigate).not.toHaveBeenCalled();
});
});
});
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/deep-link-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type Href, router } from 'expo-router';

import { resolveIncomingUrl } from '@kilocode/app-shared/universal-links';

import { setPendingDeepLink, wasLaunchLinkHandled } from './deep-link-launch';

/**
* expo-router `+native-intent` `redirectSystemPath` implementation.
*
* Load-bearing facts (past critical findings):
* 1. SYNCHRONOUS — expo-router's cold path assigns the result without `await`.
* 2. Must return FALSY for handled links — a truthy return re-dispatches the
* linking resolver and races our navigation against a reset-to-Home.
* Returning `'/'` is a bug that looks like success on tab-root rows.
* 3. `initial` is the cold/warm discriminator — never try/catch around navigate
* as a readiness probe; `router.navigate` queues rather than throws when the
* router is unmounted, so try/catch silently drops cold deep links.
*/
export function redirectSystemPath({
path,
initial,
}: {
path: string;
initial: boolean;
}): string | null {
try {
const href = resolveIncomingUrl(path);
// Untouched → default handling (and future share intent).
if (href == null) {
return path;
}
if (initial) {
// COLD: stash only. Never navigate — router isn't mounted.
// Skip when the synchronous launch capture already stashed this launch
// URL: expo-router's cold path can land AFTER the gate effect consumed
// the slot, and a restash would surface as a duplicate navigation on a
// later, unrelated effect re-run (e.g. token refresh).
if (!wasLaunchLinkHandled()) {
setPendingDeepLink(href, 'universal-link');
}
} else {
// WARM: router is mounted; group hrefs work here.
router.navigate(href as Href);
}
// Falsy in both handled cases — critical (see above).
return null;
} catch {
// A deep-link bug must never brick app launch.
return path;
}
}
87 changes: 87 additions & 0 deletions apps/mobile/src/lib/deep-link-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
_resetDeepLinkLaunchForTests,
_setGetLinkingURLForTests,
captureLaunchDeepLink,
getPendingDeepLink,
setPendingDeepLink,
} from './deep-link-launch';

describe('deep-link-launch', () => {
beforeEach(() => {
_resetDeepLinkLaunchForTests();
});

afterEach(() => {
_resetDeepLinkLaunchForTests();
});

describe('pending slot', () => {
it('is single-shot get-and-clear', () => {
setPendingDeepLink('/(app)/(tabs)/(3_profile)', 'universal-link');
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)');
expect(getPendingDeepLink()).toBeNull();
});
});

describe('source precedence (order-independent)', () => {
it('notification then universal-link leaves the link href', () => {
setPendingDeepLink('/from-notification', 'notification');
setPendingDeepLink('/from-link', 'universal-link');
expect(getPendingDeepLink()).toBe('/from-link');
});

it('universal-link then notification leaves the link href', () => {
setPendingDeepLink('/from-link', 'universal-link');
setPendingDeepLink('/from-notification', 'notification');
expect(getPendingDeepLink()).toBe('/from-link');
});

it('notification then notification leaves the latest notification href', () => {
setPendingDeepLink('/notif-1', 'notification');
setPendingDeepLink('/notif-2', 'notification');
expect(getPendingDeepLink()).toBe('/notif-2');
});
});

describe('captureLaunchDeepLink', () => {
it('stashes a mapped launch URL synchronously', () => {
_setGetLinkingURLForTests(() => 'https://app.kilo.ai/security-agent/findings');
captureLaunchDeepLink();
// Assert immediately — no await. The point of the test is synchronicity.
expect(getPendingDeepLink()).toBe(
'/(app)/(tabs)/(3_profile)/security-agent/personal/findings'
);
});

it('is a no-op when the latch is already set (slot not overwritten)', () => {
_setGetLinkingURLForTests(() => 'https://app.kilo.ai/profile');
captureLaunchDeepLink();
expect(getPendingDeepLink()).toBe('/(app)/(tabs)/(3_profile)');

// Second call must not write again even if getLinkingURL returns a new URL.
_setGetLinkingURLForTests(() => 'https://app.kilo.ai/claw');
setPendingDeepLink('/pre-existing', 'notification');
captureLaunchDeepLink();
expect(getPendingDeepLink()).toBe('/pre-existing');
});

it('is a no-op when getLinkingURL returns null', () => {
_setGetLinkingURLForTests(() => null);
captureLaunchDeepLink();
expect(getPendingDeepLink()).toBeNull();
});

it('is a no-op for an unmapped/garbage URL', () => {
_setGetLinkingURLForTests(() => 'https://app.kilo.ai/admin');
captureLaunchDeepLink();
expect(getPendingDeepLink()).toBeNull();

// Latch only sets on a successful mapped capture; garbage still no-ops.
_setGetLinkingURLForTests(() => 'not a url');
captureLaunchDeepLink();
expect(getPendingDeepLink()).toBeNull();
});
});
});
Loading