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
74 changes: 63 additions & 11 deletions apps/mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// System-WebView browser + AI chat + agents + settings. This is the companion
// app, NOT the Ungoogled Chromium engine — see docs/mobile-architecture.md.
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useState, type ReactNode } from 'react';
import { Keyboard, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { BrowserScreen } from './src/screens/BrowserScreen';
import { ChatScreen } from './src/screens/ChatScreen';
import { AgentsScreen } from './src/screens/AgentsScreen';
Expand All @@ -19,26 +20,75 @@ const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'settings', label: 'Settings', icon: '⚙️' },
];

// Inactive scenes are parked offscreen inside an overflow-hidden host instead
// of being unmounted (which destroys WebView history and chat state) or given
// `display: 'none'` (which Android can treat as a native detach). Same
// technique as react-navigation's ResourceSavingView.
const DETACHED_TOP = 100000;

function TabScene({ active, children }: { active: boolean; children: ReactNode }) {
return (
<View
style={[styles.scene, { zIndex: active ? 0 : -1 }]}
collapsable={false}
pointerEvents={active ? 'auto' : 'none'}
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
accessibilityElementsHidden={!active}
>
<View style={[styles.sceneInner, !active && styles.sceneDetached]} collapsable={false}>
{children}
</View>
</View>
);
}

export default function App() {
return (
<SafeAreaProvider>
<AppShell />
</SafeAreaProvider>
);
}

function AppShell() {
const [tab, setTab] = useState<TabKey>('browser');
// Android 15/16 enforce edge-to-edge: the app draws under the system bars,
// so the toolbar and tab bar must pad themselves out of the way explicitly.
const insets = useSafeAreaInsets();

return (
<SafeAreaView style={styles.root}>
<View
style={[
styles.root,
{ paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right },
]}
>
<StatusBar style="light" />
<View style={styles.screen}>
{tab === 'browser' && <BrowserScreen />}
{tab === 'chat' && <ChatScreen />}
{tab === 'agents' && <AgentsScreen />}
{tab === 'settings' && <SettingsScreen />}
<TabScene active={tab === 'browser'}>
<BrowserScreen isActive={tab === 'browser'} />
</TabScene>
<TabScene active={tab === 'chat'}>
<ChatScreen />
</TabScene>
<TabScene active={tab === 'agents'}>
<AgentsScreen />
</TabScene>
<TabScene active={tab === 'settings'}>
<SettingsScreen />
</TabScene>
</View>
<View style={styles.tabBar}>
<View style={[styles.tabBar, { paddingBottom: 6 + insets.bottom }]}>
{TABS.map((t) => {
const active = t.key === tab;
return (
<TouchableOpacity
key={t.key}
style={styles.tab}
onPress={() => setTab(t.key)}
onPress={() => {
if (t.key !== tab) Keyboard.dismiss();
setTab(t.key);
}}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
>
Expand All @@ -48,19 +98,21 @@ export default function App() {
);
})}
</View>
</SafeAreaView>
</View>
);
}

const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
screen: { flex: 1 },
scene: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, overflow: 'hidden' },
sceneInner: { flex: 1 },
sceneDetached: { top: DETACHED_TOP },
tabBar: {
flexDirection: 'row',
backgroundColor: theme.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.border,
paddingBottom: 6,
},
tab: { flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 },
tabIcon: { fontSize: 18 },
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/BUILD_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,36 @@ generated debug keystore for sideload testing. It is not store-signed. The job
does not use Expo EAS credits, publish an app, or commit the generated `android/`
directory.

## Android runtime smoke checklist

Run on one Android 15+ device or emulator with the sideloaded preview APK.
These behaviors are covered by component tests with a mocked native boundary;
this checklist is the real-device gate that the mocks cannot replace.

1. **Search, no account** — type `privacy first browser` in the address bar and
submit: a DuckDuckGo results page loads (no Kagi login wall).
2. **Two-page history** — from the results page open any result, then tap the
in-app `‹` button: the results page returns.
3. **Tab-state survival** — load a page, scroll partway, switch to Chat, type a
draft (don't send), visit Agents and Settings, return to Browse: the same
page and scroll position are still there; return to Chat: the draft is
still there.
4. **System Back** — with two pages of history and Browse active, the system
back gesture/button goes to the previous page; on the first page it leaves
the app. With Chat active it leaves the app immediately, even when the
hidden Browse tab still has history.
5. **System-bar insets** — the URL toolbar sits fully below the status bar and
the tab bar fully above the gesture/navigation bar, in portrait, with no
content underlapping either bar.
6. **Popup links** — open a `target="_blank"` link (e.g. a result on a site
that opens externally): it loads visibly in the same tab and Back returns
to the referring page. A `javascript:` or `data:` popup does nothing.
7. **Cookie wording** — Settings → Privacy shows "Blocked in browser tab" on
Android (an iOS build must show the WebKit wording instead).

Record the device model, Android version, and each step's result honestly —
an APK that has not passed this list is not release-ready.

## EAS preview build

The app is linked to the `profullstack/tronbrowserdev` EAS project. Cloud builds
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ Bundle ids: `dev.tronbrowser.app` (iOS + Android).

## Features

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

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

## Tests

`pnpm test` runs the URL/search unit tests plus component tests that render
the real `App`/screens with only the native boundary mocked (`test/mocks/*`,
aliased in `vitest.config.ts`): tab-state preservation, hardware-Back policy,
safe-area insets, `window.open` handling, and platform cookie wording.

## EAS (builds & submission)

Linked to the EAS project **profullstack/tronbrowserdev**
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-webview": "13.16.1"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.2.17",
"@types/react-test-renderer": "^19.1.0",
"babel-preset-expo": "~57.0.5",
"react-test-renderer": "19.2.3",
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
Expand Down
38 changes: 34 additions & 4 deletions apps/mobile/src/lib/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { HOME, normalizeUrl } from './navigation';
import { HOME, navigableHttpUrl, normalizeUrl } from './navigation';

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

it('searches ordinary text', () => {
expect(normalizeUrl('privacy first browser')).toBe(
'https://kagi.com/search?q=privacy%20first%20browser',
'https://duckduckgo.com/?q=privacy%20first%20browser',
);
});

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

it('does not treat domain-looking text with spaces as a URL', () => {
expect(normalizeUrl('example.com malicious suffix')).toBe(
'https://kagi.com/search?q=example.com%20malicious%20suffix',
'https://duckduckgo.com/?q=example.com%20malicious%20suffix',
);
});

it('searches with a no-account engine, not Kagi', () => {
// Kagi needs a subscription after its trial; a fresh install must be able
// to search out of the box, matching the desktop DuckDuckGo default.
expect(normalizeUrl('some query')).not.toContain('kagi.com');
});
});

describe('navigableHttpUrl', () => {
it('accepts absolute HTTP(S) URLs', () => {
expect(navigableHttpUrl('https://example.com/next?page=2')).toBe(
'https://example.com/next?page=2',
);
expect(navigableHttpUrl('http://localhost:8080/dev')).toBe(
'http://localhost:8080/dev',
);
});

it('rejects script and data schemes instead of falling back to search', () => {
expect(navigableHttpUrl('javascript:alert(document.cookie)')).toBeNull();
expect(navigableHttpUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
});

it('rejects other non-web schemes and relative junk', () => {
expect(navigableHttpUrl('intent://scan/#Intent;scheme=zxing;end')).toBeNull();
expect(navigableHttpUrl('about:blank')).toBeNull();
expect(navigableHttpUrl('file:///etc/passwd')).toBeNull();
expect(navigableHttpUrl('example.com/no-scheme')).toBeNull();
expect(navigableHttpUrl(' ')).toBeNull();
});
});
24 changes: 23 additions & 1 deletion apps/mobile/src/lib/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ const DOMAIN_OR_IP =
/^(?:(?:[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;

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

/**
Expand Down Expand Up @@ -39,3 +42,22 @@ export function normalizeUrl(input: string): string {

return searchUrl(trimmed);
}

/**
* Validate a URL that page content asked us to open (`window.open`,
* `target="_blank"`). Unlike address-bar input there is no search fallback:
* only an absolute HTTP(S) URL may navigate the tab, and anything else
* (javascript:, data:, intent:, about:, malformed) is dropped entirely.
*/
export function navigableHttpUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.toString()
: null;
} catch {
return null;
}
}
41 changes: 38 additions & 3 deletions apps/mobile/src/screens/BrowserScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
BackHandler,
Platform,
StyleSheet,
Text,
Expand All @@ -9,7 +10,7 @@ import {
View,
} from 'react-native';
import { WebView } from 'react-native-webview';
import { HOME, normalizeUrl } from '../lib/navigation';
import { HOME, navigableHttpUrl, normalizeUrl } from '../lib/navigation';
import { theme } from '../theme';

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

// Android system Back pops WebView history. Subscribe only while this tab is
// the visible one AND there is history to pop; otherwise no handler exists at
// all, so the event keeps its default meaning (leave the app) and a hidden
// Browser tab can never swallow it.
useEffect(() => {
if (Platform.OS !== 'android' || !isActive || !canGoBack) return;
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
webRef.current?.goBack();
return true;
});
return () => subscription.remove();
}, [isActive, canGoBack]);

const go = () => {
const next = normalizeUrl(address);
setUri(next);
setAddress(next);
};

// Android hands `window.open` / `target="_blank"` to a detached WebView the
// user never sees. Show those navigations in this single tab instead — but a
// page-supplied URL only reaches `source` once validated as plain HTTP(S);
// javascript:/data:/intent: targets are dropped.
const openWindowInThisTab = (targetUrl: string) => {
const next = navigableHttpUrl(targetUrl);
if (!next) return;
// In-page navigation can leave `uri` unchanged. Updating the same source
// would do nothing; navigate the existing WebView without discarding history.
if (next === uri) {
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`);
}
setUri(next);
setAddress(next);
};

return (
<View style={styles.container}>
<View style={styles.bar}>
Expand Down Expand Up @@ -93,6 +127,7 @@ export function BrowserScreen() {
setCanGoBack(state.canGoBack);
setCanGoForward(state.canGoForward);
}}
onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)}
// Privacy-leaning defaults consistent with the desktop ethos.
thirdPartyCookiesEnabled={false}
allowsInlineMediaPlayback
Expand Down
Loading
Loading