Skip to content

Commit 40d7089

Browse files
Preserve mobile browsing state and Android back navigation
1 parent 662962b commit 40d7089

24 files changed

Lines changed: 1170 additions & 24 deletions

‎apps/mobile/App.tsx‎

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// System-WebView browser + AI chat + agents + settings. This is the companion
33
// app, NOT the Ungoogled Chromium engine — see docs/mobile-architecture.md.
44
import { StatusBar } from 'expo-status-bar';
5-
import { useState } from 'react';
6-
import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
5+
import { useState, type ReactNode } from 'react';
6+
import { Keyboard, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
7+
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
78
import { BrowserScreen } from './src/screens/BrowserScreen';
89
import { ChatScreen } from './src/screens/ChatScreen';
910
import { AgentsScreen } from './src/screens/AgentsScreen';
@@ -19,26 +20,75 @@ const TABS: { key: TabKey; label: string; icon: string }[] = [
1920
{ key: 'settings', label: 'Settings', icon: '⚙️' },
2021
];
2122

23+
// Inactive scenes are parked offscreen inside an overflow-hidden host instead
24+
// of being unmounted (which destroys WebView history and chat state) or given
25+
// `display: 'none'` (which Android can treat as a native detach). Same
26+
// technique as react-navigation's ResourceSavingView.
27+
const DETACHED_TOP = 100000;
28+
29+
function TabScene({ active, children }: { active: boolean; children: ReactNode }) {
30+
return (
31+
<View
32+
style={[styles.scene, { zIndex: active ? 0 : -1 }]}
33+
collapsable={false}
34+
pointerEvents={active ? 'auto' : 'none'}
35+
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
36+
accessibilityElementsHidden={!active}
37+
>
38+
<View style={[styles.sceneInner, !active && styles.sceneDetached]} collapsable={false}>
39+
{children}
40+
</View>
41+
</View>
42+
);
43+
}
44+
2245
export default function App() {
46+
return (
47+
<SafeAreaProvider>
48+
<AppShell />
49+
</SafeAreaProvider>
50+
);
51+
}
52+
53+
function AppShell() {
2354
const [tab, setTab] = useState<TabKey>('browser');
55+
// Android 15/16 enforce edge-to-edge: the app draws under the system bars,
56+
// so the toolbar and tab bar must pad themselves out of the way explicitly.
57+
const insets = useSafeAreaInsets();
2458

2559
return (
26-
<SafeAreaView style={styles.root}>
60+
<View
61+
style={[
62+
styles.root,
63+
{ paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right },
64+
]}
65+
>
2766
<StatusBar style="light" />
2867
<View style={styles.screen}>
29-
{tab === 'browser' && <BrowserScreen />}
30-
{tab === 'chat' && <ChatScreen />}
31-
{tab === 'agents' && <AgentsScreen />}
32-
{tab === 'settings' && <SettingsScreen />}
68+
<TabScene active={tab === 'browser'}>
69+
<BrowserScreen isActive={tab === 'browser'} />
70+
</TabScene>
71+
<TabScene active={tab === 'chat'}>
72+
<ChatScreen />
73+
</TabScene>
74+
<TabScene active={tab === 'agents'}>
75+
<AgentsScreen />
76+
</TabScene>
77+
<TabScene active={tab === 'settings'}>
78+
<SettingsScreen />
79+
</TabScene>
3380
</View>
34-
<View style={styles.tabBar}>
81+
<View style={[styles.tabBar, { paddingBottom: 6 + insets.bottom }]}>
3582
{TABS.map((t) => {
3683
const active = t.key === tab;
3784
return (
3885
<TouchableOpacity
3986
key={t.key}
4087
style={styles.tab}
41-
onPress={() => setTab(t.key)}
88+
onPress={() => {
89+
if (t.key !== tab) Keyboard.dismiss();
90+
setTab(t.key);
91+
}}
4292
accessibilityRole="tab"
4393
accessibilityState={{ selected: active }}
4494
>
@@ -48,19 +98,21 @@ export default function App() {
4898
);
4999
})}
50100
</View>
51-
</SafeAreaView>
101+
</View>
52102
);
53103
}
54104

55105
const styles = StyleSheet.create({
56106
root: { flex: 1, backgroundColor: theme.bg },
57107
screen: { flex: 1 },
108+
scene: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, overflow: 'hidden' },
109+
sceneInner: { flex: 1 },
110+
sceneDetached: { top: DETACHED_TOP },
58111
tabBar: {
59112
flexDirection: 'row',
60113
backgroundColor: theme.surface,
61114
borderTopWidth: StyleSheet.hairlineWidth,
62115
borderTopColor: theme.border,
63-
paddingBottom: 6,
64116
},
65117
tab: { flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 },
66118
tabIcon: { fontSize: 18 },

‎apps/mobile/BUILD_READINESS.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,36 @@ generated debug keystore for sideload testing. It is not store-signed. The job
4646
does not use Expo EAS credits, publish an app, or commit the generated `android/`
4747
directory.
4848

49+
## Android runtime smoke checklist
50+
51+
Run on one Android 15+ device or emulator with the sideloaded preview APK.
52+
These behaviors are covered by component tests with a mocked native boundary;
53+
this checklist is the real-device gate that the mocks cannot replace.
54+
55+
1. **Search, no account** — type `privacy first browser` in the address bar and
56+
submit: a DuckDuckGo results page loads (no Kagi login wall).
57+
2. **Two-page history** — from the results page open any result, then tap the
58+
in-app `‹` button: the results page returns.
59+
3. **Tab-state survival** — load a page, scroll partway, switch to Chat, type a
60+
draft (don't send), visit Agents and Settings, return to Browse: the same
61+
page and scroll position are still there; return to Chat: the draft is
62+
still there.
63+
4. **System Back** — with two pages of history and Browse active, the system
64+
back gesture/button goes to the previous page; on the first page it leaves
65+
the app. With Chat active it leaves the app immediately, even when the
66+
hidden Browse tab still has history.
67+
5. **System-bar insets** — the URL toolbar sits fully below the status bar and
68+
the tab bar fully above the gesture/navigation bar, in portrait, with no
69+
content underlapping either bar.
70+
6. **Popup links** — open a `target="_blank"` link (e.g. a result on a site
71+
that opens externally): it loads visibly in the same tab and Back returns
72+
to the referring page. A `javascript:` or `data:` popup does nothing.
73+
7. **Cookie wording** — Settings → Privacy shows "Blocked in browser tab" on
74+
Android (an iOS build must show the WebKit wording instead).
75+
76+
Record the device model, Android version, and each step's result honestly —
77+
an APK that has not passed this list is not release-ready.
78+
4979
## EAS preview build
5080

5181
The app is linked to the `profullstack/tronbrowserdev` EAS project. Cloud builds

‎apps/mobile/README.md‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,18 @@ Bundle ids: `dev.tronbrowser.app` (iOS + Android).
2828

2929
## Features
3030

31-
Implemented screens (tabbed shell, `App.tsx`):
31+
Implemented screens (tabbed shell, `App.tsx`). Every tab stays mounted across
32+
switches — WebView history/scroll, chat messages, and drafts survive — while
33+
inactive tabs are hidden from touch and accessibility. Safe areas come from
34+
`react-native-safe-area-context` (Android 15/16 edge-to-edge), not React
35+
Native's deprecated iOS-only `SafeAreaView`.
3236

3337
- **Browse** — in-app browser via `react-native-webview` (system engine),
34-
URL/search bar, back/reload, third-party cookies blocked.
38+
URL/search bar with a DuckDuckGo default that needs no account (the desktop
39+
correction), back/forward/reload. Android hardware Back walks page history
40+
only while this tab is active; `window.open` / `target="_blank"` opens in
41+
the same tab after HTTP(S) validation; third-party cookies are blocked on
42+
Android (on iOS the WKWebView cookie policy belongs to WebKit).
3543
- **Chat** — AI chat UI; the provider seam is `src/lib/ai.ts`
3644
(set `EXPO_PUBLIC_AI_ENDPOINT`, else offline echo).
3745
- **Agents** — agent dashboard (sample data → wire `@tronbrowser/agent-runtime`).
@@ -40,6 +48,13 @@ Implemented screens (tabbed shell, `App.tsx`):
4048
Still to wire (PRD §Mobile): real model provider, sync backend, voice,
4149
push notifications.
4250

51+
## Tests
52+
53+
`pnpm test` runs the URL/search unit tests plus component tests that render
54+
the real `App`/screens with only the native boundary mocked (`test/mocks/*`,
55+
aliased in `vitest.config.ts`): tab-state preservation, hardware-Back policy,
56+
safe-area insets, `window.open` handling, and platform cookie wording.
57+
4358
## EAS (builds & submission)
4459

4560
Linked to the EAS project **profullstack/tronbrowserdev**

‎apps/mobile/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,15 @@
2929
"expo-status-bar": "~57.0.1",
3030
"react": "19.2.3",
3131
"react-native": "0.86.2",
32+
"react-native-safe-area-context": "~5.7.0",
3233
"react-native-webview": "13.16.1"
3334
},
3435
"devDependencies": {
3536
"@babel/core": "^7.25.0",
3637
"@types/react": "~19.2.17",
38+
"@types/react-test-renderer": "^19.1.0",
3739
"babel-preset-expo": "~57.0.5",
40+
"react-test-renderer": "19.2.3",
3841
"typescript": "^5.6.3",
3942
"vitest": "^2.1.4"
4043
}

‎apps/mobile/src/lib/navigation.test.ts‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { HOME, normalizeUrl } from './navigation';
2+
import { HOME, navigableHttpUrl, normalizeUrl } from './navigation';
33

44
describe('normalizeUrl', () => {
55
it('returns home for blank input', () => {
@@ -23,19 +23,49 @@ describe('normalizeUrl', () => {
2323

2424
it('searches ordinary text', () => {
2525
expect(normalizeUrl('privacy first browser')).toBe(
26-
'https://kagi.com/search?q=privacy%20first%20browser',
26+
'https://duckduckgo.com/?q=privacy%20first%20browser',
2727
);
2828
});
2929

3030
it('searches unsupported schemes instead of loading them', () => {
3131
expect(normalizeUrl('javascript:alert(1)')).toBe(
32-
'https://kagi.com/search?q=javascript%3Aalert(1)',
32+
'https://duckduckgo.com/?q=javascript%3Aalert(1)',
3333
);
3434
});
3535

3636
it('does not treat domain-looking text with spaces as a URL', () => {
3737
expect(normalizeUrl('example.com malicious suffix')).toBe(
38-
'https://kagi.com/search?q=example.com%20malicious%20suffix',
38+
'https://duckduckgo.com/?q=example.com%20malicious%20suffix',
3939
);
4040
});
41+
42+
it('searches with a no-account engine, not Kagi', () => {
43+
// Kagi needs a subscription after its trial; a fresh install must be able
44+
// to search out of the box, matching the desktop DuckDuckGo default.
45+
expect(normalizeUrl('some query')).not.toContain('kagi.com');
46+
});
47+
});
48+
49+
describe('navigableHttpUrl', () => {
50+
it('accepts absolute HTTP(S) URLs', () => {
51+
expect(navigableHttpUrl('https://example.com/next?page=2')).toBe(
52+
'https://example.com/next?page=2',
53+
);
54+
expect(navigableHttpUrl('http://localhost:8080/dev')).toBe(
55+
'http://localhost:8080/dev',
56+
);
57+
});
58+
59+
it('rejects script and data schemes instead of falling back to search', () => {
60+
expect(navigableHttpUrl('javascript:alert(document.cookie)')).toBeNull();
61+
expect(navigableHttpUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
62+
});
63+
64+
it('rejects other non-web schemes and relative junk', () => {
65+
expect(navigableHttpUrl('intent://scan/#Intent;scheme=zxing;end')).toBeNull();
66+
expect(navigableHttpUrl('about:blank')).toBeNull();
67+
expect(navigableHttpUrl('file:///etc/passwd')).toBeNull();
68+
expect(navigableHttpUrl('example.com/no-scheme')).toBeNull();
69+
expect(navigableHttpUrl(' ')).toBeNull();
70+
});
4171
});

‎apps/mobile/src/lib/navigation.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ const DOMAIN_OR_IP =
44
/^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|(?:\d{1,3}\.){3}\d{1,3}|localhost)(?::\d{1,5})?(?:[/?#][^\s]*)?$/i;
55

66
function searchUrl(query: string): string {
7-
return `https://kagi.com/search?q=${encodeURIComponent(query)}`;
7+
// DuckDuckGo answers without an account. Kagi is subscription-only past its
8+
// trial, so defaulting to it left a fresh install with a broken search box —
9+
// the same out-of-box failure the desktop launcher already corrects.
10+
return `https://duckduckgo.com/?q=${encodeURIComponent(query)}`;
811
}
912

1013
/**
@@ -39,3 +42,22 @@ export function normalizeUrl(input: string): string {
3942

4043
return searchUrl(trimmed);
4144
}
45+
46+
/**
47+
* Validate a URL that page content asked us to open (`window.open`,
48+
* `target="_blank"`). Unlike address-bar input there is no search fallback:
49+
* only an absolute HTTP(S) URL may navigate the tab, and anything else
50+
* (javascript:, data:, intent:, about:, malformed) is dropped entirely.
51+
*/
52+
export function navigableHttpUrl(raw: string): string | null {
53+
const trimmed = raw.trim();
54+
if (!trimmed) return null;
55+
try {
56+
const parsed = new URL(trimmed);
57+
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
58+
? parsed.toString()
59+
: null;
60+
} catch {
61+
return null;
62+
}
63+
}

‎apps/mobile/src/screens/BrowserScreen.tsx‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useRef, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import {
33
ActivityIndicator,
4+
BackHandler,
45
Platform,
56
StyleSheet,
67
Text,
@@ -9,7 +10,7 @@ import {
910
View,
1011
} from 'react-native';
1112
import { WebView } from 'react-native-webview';
12-
import { HOME, normalizeUrl } from '../lib/navigation';
13+
import { HOME, navigableHttpUrl, normalizeUrl } from '../lib/navigation';
1314
import { theme } from '../theme';
1415

1516
/**
@@ -19,21 +20,54 @@ import { theme } from '../theme';
1920
* iOS (mandatory), the system WebView on Android. It is deliberately NOT the
2021
* Ungoogled Chromium engine (see docs/mobile-architecture.md — the engine ships
2122
* via the native Android build and the Linux-phone desktop build, not Expo).
23+
*
24+
* The screen stays mounted while other tabs are shown (App.tsx keeps every
25+
* scene alive), so `isActive` — not mount state — says whether this tab owns
26+
* the Android hardware Back button.
2227
*/
23-
export function BrowserScreen() {
28+
export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
2429
const webRef = useRef<WebView>(null);
2530
const [address, setAddress] = useState(HOME);
2631
const [uri, setUri] = useState(HOME);
2732
const [loading, setLoading] = useState(false);
2833
const [canGoBack, setCanGoBack] = useState(false);
2934
const [canGoForward, setCanGoForward] = useState(false);
3035

36+
// Android system Back pops WebView history. Subscribe only while this tab is
37+
// the visible one AND there is history to pop; otherwise no handler exists at
38+
// all, so the event keeps its default meaning (leave the app) and a hidden
39+
// Browser tab can never swallow it.
40+
useEffect(() => {
41+
if (Platform.OS !== 'android' || !isActive || !canGoBack) return;
42+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
43+
webRef.current?.goBack();
44+
return true;
45+
});
46+
return () => subscription.remove();
47+
}, [isActive, canGoBack]);
48+
3149
const go = () => {
3250
const next = normalizeUrl(address);
3351
setUri(next);
3452
setAddress(next);
3553
};
3654

55+
// Android hands `window.open` / `target="_blank"` to a detached WebView the
56+
// user never sees. Show those navigations in this single tab instead — but a
57+
// page-supplied URL only reaches `source` once validated as plain HTTP(S);
58+
// javascript:/data:/intent: targets are dropped.
59+
const openWindowInThisTab = (targetUrl: string) => {
60+
const next = navigableHttpUrl(targetUrl);
61+
if (!next) return;
62+
// In-page navigation can leave `uri` unchanged. Updating the same source
63+
// would do nothing; navigate the existing WebView without discarding history.
64+
if (next === uri) {
65+
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`);
66+
}
67+
setUri(next);
68+
setAddress(next);
69+
};
70+
3771
return (
3872
<View style={styles.container}>
3973
<View style={styles.bar}>
@@ -93,6 +127,7 @@ export function BrowserScreen() {
93127
setCanGoBack(state.canGoBack);
94128
setCanGoForward(state.canGoForward);
95129
}}
130+
onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)}
96131
// Privacy-leaning defaults consistent with the desktop ethos.
97132
thirdPartyCookiesEnabled={false}
98133
allowsInlineMediaPlayback

0 commit comments

Comments
 (0)