Ship Android app by wrapping the web build with Capacitor (#20) (#28)
Unit Tests / test (push) Successful in 11s

## Summary
- Closes #20
- Add Capacitor 7 to `llm-fe/` (`capacitor.config.ts`, appId `com.aimloperations.chat`, `webDir: build`) and commit the generated `android/` project
- npm scripts: `build:mobile` (`.env.mobile` + CRA build + `cap sync`), `android:open`, `android:sync`, `assets:generate`
- `.env.mobile` defaults to prod backend; override independently of web deploys
- Native chrome: Android back button (history / exit at root), status bar + keyboard resize, safe-area CSS; Preferences registered for JWT mirror (#22)
- Icons/splash via `@capacitor/assets`; signing via optional `keystore.properties`; docs in `llm-fe/ANDROID.md` + root README

Blockers #22 / #23 / #24 already on master.

## Test plan
- [x] `npm run test:ci` (79 tests)
- [x] `npm run build` + `npx cap add android` / `cap sync`
- [ ] `npm run build:mobile` + open in Android Studio on a machine with SDK
- [ ] Emulator/device QA: login, chat stream, theme, back button, keyboard/safe-area, WS resume
- [ ] Generate upload keystore outside repo; `./gradlew bundleRelease` for internal testing trackReviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
2026-07-26 17:12:48 -07:00
parent b7f23eb16c
commit a4a28ef13e
94 changed files with 5541 additions and 9 deletions
+4
View File
@@ -1,8 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import './llm-fe/platform/nativeSafeArea.css';
import App from './App';
import AppRouter from './llm-fe/platform/AppRouter';
import { initNativeShell } from './llm-fe/platform/initNativeShell';
import { AuthProvider } from './llm-fe/contexts/AuthContext';
import { AccountProvider } from './llm-fe/contexts/AccountContext';
import { WebSocketProvider } from './llm-fe/contexts/WebSocketContext';
@@ -11,6 +13,8 @@ import { ConversationProvider } from './llm-fe/contexts/ConversationContext';
import { MessageProvider } from './llm-fe/contexts/MessageContext';
import { PreferenceProvider } from './llm-fe/contexts/PreferencesContext';
void initNativeShell();
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
@@ -0,0 +1,14 @@
/**
* Boot native Capacitor chrome (back button, status bar, keyboard).
* Safe to call from index.tsx; no-ops when not running in a Capacitor shell.
*/
import { Preferences } from '@capacitor/preferences';
import { setupAndroidBackButton, setupNativeChrome } from './nativeChrome';
// Side-effect import registers Preferences on window.Capacitor.Plugins for tokenStorage.
void Preferences;
export async function initNativeShell() {
await setupNativeChrome();
return setupAndroidBackButton();
}
@@ -0,0 +1,58 @@
/**
* Android hardware back button + keyboard / status-bar insets for Capacitor (#20).
* No-ops on web.
*/
import { isNativePlatform } from './nativePlatform';
/**
* Register Android back-button: pop history when possible, else exit app at root.
* Call once after React mounts.
* @returns {Promise<(() => void) | undefined>} unsubscribe
*/
export async function setupAndroidBackButton() {
if (!isNativePlatform()) {
return undefined;
}
const { App } = await import('@capacitor/app');
const handle = await App.addListener('backButton', ({ canGoBack }) => {
if (canGoBack || (typeof window !== 'undefined' && window.history.length > 1)) {
window.history.back();
return;
}
App.exitApp();
});
return () => {
handle.remove();
};
}
/**
* Configure status bar + keyboard resize so MUI layout respects safe areas.
* @returns {Promise<void>}
*/
export async function setupNativeChrome() {
if (!isNativePlatform()) {
return;
}
try {
const { StatusBar, Style } = await import('@capacitor/status-bar');
await StatusBar.setOverlaysWebView({ overlay: false });
await StatusBar.setStyle({ style: Style.Dark });
} catch {
/* plugin optional at runtime */
}
try {
const { Keyboard, KeyboardResize } = await import('@capacitor/keyboard');
if (typeof Keyboard.setResizeMode === 'function') {
await Keyboard.setResizeMode({ mode: KeyboardResize.Body });
}
} catch {
/* Keyboard.setResizeMode is Android-only; ignore on iOS / missing plugin */
}
}
@@ -0,0 +1,22 @@
import { setupAndroidBackButton, setupNativeChrome } from './nativeChrome';
describe('nativeChrome', () => {
const originalCapacitor = window.Capacitor;
afterEach(() => {
window.Capacitor = originalCapacitor;
jest.resetModules();
jest.clearAllMocks();
});
test('setupAndroidBackButton is no-op without Capacitor', async () => {
delete window.Capacitor;
const unsub = await setupAndroidBackButton();
expect(unsub).toBeUndefined();
});
test('setupNativeChrome is no-op without Capacitor', async () => {
delete window.Capacitor;
await expect(setupNativeChrome()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,14 @@
/* Capacitor / notch safe-area insets (#20). Harmless on desktop web. */
html {
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0)
env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
}
body {
/* Keyboard plugin resizes body; keep root scrollable within inset. */
min-height: 100%;
}
#root {
min-height: 100%;
}