Skip to content

Package and test an application with Capacitor

You will package the prepared Delivery Board web client as an Android application with Capacitor 8. The repository will keep one React product and two explicit build modes:

  • the normal web build uses the validated Express API through relative /api requests; and
  • the Android build uses visibly labeled fictional demonstration data because this level does not deploy a public API.

You will create and commit the Android native project, copy the current Vite build through Capacitor’s build-and-sync workflow, run the installed application on an Android emulator or authorized device, and verify system bars, safe areas, touch, routes, system Back, lifecycle, portrait, landscape, and reset behavior.

What you will practice

  • Explain how React, Vite, Capacitor, Android WebView, Android Studio, Gradle, and the operating system contribute different parts of one application.
  • Verify current tool requirements before generating a native project.
  • Configure an existing Vite application with a stable application ID, product name, and dist web directory.
  • Select API or demonstration behavior through validated public build configuration.
  • Implement a typed in-memory WorkItemsApi adapter that protects its seed and returned values from mutation.
  • Keep demonstration data visibly distinct from server, persistence, deployment, and offline-sync evidence.
  • Build web assets before syncing them into the Android project.
  • Use Capacitor System Bars fallback variables for safe-area layout in Android WebView.
  • Preserve React Router behavior through Android system Back without installing an unnecessary custom listener.
  • Distinguish web build, copied asset, native build, installed runtime, and WebView debugging evidence.
  • Verify native accessibility, orientation, lifecycle, and reset behavior without claiming untested iOS support.
  • New: Capacitor 8 core and CLI, Android platform package, App plugin, capacitor.config.ts, .env.android, validated runtime mode, typed demonstration adapter, demonstration notice, Android project, Gradle wrapper, build-and-sync scripts, System Bars inset fallback, default Android Back handling, installed-runtime matrix, and native evidence.
  • Reused: feature/cross-platform, Vite dist, Node 24, npm lockfile, React components and routes, WorkItemsApi, WorkItem, immutable updates, loading and feedback states, Sass safe-area rules, semantic controls, 2.75-rem targets, Vitest, Testing Library, Express API mode, browser matrices, documentation, Git review, and focused commits.

Starting point

Before you start

  • The cross-platform design commit exists on a clean feature/cross-platform branch.
  • The branch contains docs/cross-platform-design.md, zoomable viewport metadata, dynamic viewport height, safe-area layout, comfortable controls, and recorded browser evidence.
  • The normal web build uses relative /api requests and passes the complete Stage 3 API and client gate.
  • The existing App accepts an injected WorkItemsApi in tests.
  • Node 24 LTS meets the current Capacitor 8 core requirement of Node 22 or newer.
  • Current supported Android Studio, one Android SDK platform, and an API 24 or newer emulator image are installed before native generation.
  • Virtualization is available for the emulator, or an authorized Android device is available with approved debugging access.
  • No iOS project, Mac, Xcode, public API, database, authentication system, store account, app signing release, or production deployment is required.
Current state
The web client is prepared for mobile layout, but the repository has no Capacitor configuration, Android native project, packaged build mode, demonstration adapter, System Bars fallback, native build script, installed application, or Android runtime evidence.
First action
Open docs/cross-platform-design.md, verify every browser row and native-pending row, then record installed Node, npm, Android Studio, SDK, emulator or device, and current branch versions before installing packages.
First checkpoint
Capacitor configuration identifies Delivery Board, the stable package ID, dist output, default Android Back handling, and System Bars inset handling; normal API mode still passes; and no native project exists until that configuration has been reviewed.
Help trigger
Use the nearest recovery note or ask for help if the installed Capacitor packages have different major versions, Node or Android Studio is below the current requirement, Android SDK or virtualization is missing, the chosen application ID is uncertain, webDir does not contain the built index.html, normal build shows demonstration data, Android build requests /api, a sync copies stale assets, generated files are edited without an owner, system bars cover content, Android Back loops or exits from a detail route, background and resume lose state unexpectedly, an emulator result is being described as physical-device proof, or a native failure is being hidden by browser evidence.

You have completed this lesson when:

  • work continues from the clean cross-platform-design commit on feature/cross-platform;
  • the environment record names actual Node, npm, Capacitor, Android Studio, SDK, emulator or device, WebView or Chrome, operating system, branch, and commit versions;
  • @capacitor/core, @capacitor/android, and @capacitor/app use the same current major version as the development-only @capacitor/cli;
  • capacitor.config.ts uses app ID dk.aspit.deliveryboard, app name Delivery Board, and web directory dist;
  • configuration contains no server.url, cleartext development host, credential, secret, or public-deployment claim;
  • the App plugin’s default Android Back handler remains enabled and no custom back listener replaces it without a new product requirement;
  • System Bars remain visible, use the default icon style, and inject CSS inset variables;
  • .env.android contains only the public value VITE_DATA_MODE=demo;
  • absent VITE_DATA_MODE selects API mode, demo selects demonstration mode, and every other value stops startup with a bounded configuration error;
  • normal npm run build keeps API mode and npm run build:android selects demonstration mode;
  • createDemoWorkItemsApi owns copied fictional records, returns copies, updates one record immutably, rejects an unknown ID, and respects an already-aborted signal;
  • demonstration mode shows a persistent visible notice that data is fictional, memory-only, and reset on reload or process termination;
  • API mode does not show the demonstration notice and continues to use the validated HTTP adapter;
  • tests cover runtime-mode parsing, demo list isolation, update isolation, unknown ID, abort, and visible mode notice;
  • the design lesson’s safe-area variables use System Bars injected values first and browser env() values as fallback;
  • npx cap add android generates one Android project from the reviewed configuration;
  • the generated Android source and Gradle wrapper are committed, while build outputs, copied web assets, local SDK paths, IDE caches, and secrets remain ignored;
  • the build order is explicit: typecheck and Vite Android build → Capacitor sync Android → native build and install;
  • npx cap run android --no-sync or Android Studio installs the already-synced build on an API 24 or newer target;
  • the installed app launches without Vite, Express, or another development server;
  • Android shows the demonstration notice and never requests /api, 127.0.0.1, or a private development host;
  • native checks cover system bars, safe areas, portrait, landscape, touch, accessible names, focus where available, routes, system Back, background, resume, termination reset, long content, and WebView Console;
  • normal web API build, Android demo build, all tests, typecheck, lint, and browser evidence still pass after native files are added;
  • README and the design document record exact commands, native evidence, blocked conditions, generated-file policy, data boundaries, recovery, and current limits; and
  • a focused Capacitor packaging commit exists on a clean feature branch.

Verify the Android environment before installation

Section titled “Verify the Android environment before installation”

The Capacitor 8 documentation currently requires Node 22 or newer. It also requires Android Studio and an Android SDK for Android development. At the time of this lesson, the current documentation names Android Studio 2025.2.1 as the minimum and supports Android API 24 or newer. Check the current official page because these tool requirements change.

Record evidence before package changes:

Inspect the JavaScript and Android environment
node --version
npm --version
git status
git log -1 --oneline

In Android Studio, record:

  • Android Studio version from Help → About;
  • SDK location from Settings → Languages & Frameworks → Android SDK;
  • installed SDK platform and API number;
  • installed SDK tools;
  • emulator name, API, orientation, and architecture; and
  • whether the emulator can start and reach its home screen.

Android Studio supplies the appropriate JDK for the current toolchain. Do not install a random JDK or edit Gradle versions before reading the first exact native diagnostic.

If the required native environment is unavailable, record Blocked: Android environment and the missing tool, version, permission, virtualization support, or device. Arrange an approved school machine or teacher-supported environment. Responsive browser evidence cannot complete the native requirement.

Verify the clean lesson commit, then install Capacitor 8 packages:

Install the required Capacitor packages
npm install @capacitor/core@^8 @capacitor/android@^8 @capacitor/app@^8
npm install --save-dev @capacitor/cli@^8
npm ls @capacitor/core @capacitor/android @capacitor/app @capacitor/cli

All four installed packages must resolve to major version 8. Do not mix a newer CLI major with an older core or platform package. Commit package.json and package-lock.json; do not commit node_modules.

Run the existing gate before initialization:

Protect the accepted web application
npm test
npm run typecheck
npm run lint
npm run build

Package installation must not select demonstration mode or change a user-visible web result.

Initialize reviewed Capacitor configuration

Section titled “Initialize reviewed Capacitor configuration”

Run the initializer:

Create the Capacitor configuration
npx cap init

Answer:

  • App name: Delivery Board
  • App ID: dk.aspit.deliveryboard
  • Web assets directory: dist

Replace or reconcile the generated configuration with this typed result:

capacitor.config.ts
/// <reference types="@capacitor/app" />
import type { CapacitorConfig } from '@capacitor/cli';
const config = {
appId: 'dk.aspit.deliveryboard',
appName: 'Delivery Board',
webDir: 'dist',
loggingBehavior: 'debug',
zoomEnabled: true,
plugins: {
App: {
disableBackButtonHandler: false,
},
SystemBars: {
hidden: false,
insetsHandling: 'css',
style: 'DEFAULT',
},
},
} satisfies CapacitorConfig;
export default config;

satisfies checks the configuration without changing the specific value types. loggingBehavior: 'debug' keeps native and redirected JavaScript logs out of release builds while making the debug lesson inspectable.

The configuration deliberately omits server.url. A server URL can support live reload, but it can also make a native project depend on a development computer and cleartext network access. This lesson tests copied built assets. zoomEnabled: true lets a user zoom the Android WebView instead of relying only on the initial viewport scale.

The System Bars API is bundled with Capacitor core. Its default CSS inset handling injects --safe-area-inset-* variables on Android when WebView does not supply correct env() values. Keep the system bars visible.

Installing @capacitor/app enables its Android behavior. The default handler is active because disableBackButtonHandler is false.

Do not call App.addListener('backButton', ...) in this lesson. Adding that listener disables the default behavior and makes your code responsible for history and app exit. The existing router history does not need a custom policy yet.

Verify configuration before platform generation

Section titled “Verify configuration before platform generation”

Run:

Inspect the Capacitor configuration
npx cap ls
npm run build
npx cap sync android

The final command must fail because the Android platform does not exist yet. Record that expected precondition, not as a product defect. Confirm that dist/index.html exists before any later sync.

Checkpoint: Capacitor configuration is explicit before native generation

What now works
Compatible version-8 packages are installed, the web gate passes, configuration names the stable app and dist output, system bars and default Back handling are deliberate, no development host is embedded, and Android has not yet been generated.
Files changed
package.json, package-lock.json, capacitor.config.ts, docs/cross-platform-design.md
What remains
Implement and test the build-mode boundary plus a visibly labeled, copied, in-memory demonstration adapter.
Next action
Create .env.android with the public demo value, then write the runtime-mode parser before creating demonstration data.
If it does not work
Restore the package lock and configuration to the last passing state, verify one Capacitor major across packages, build dist, and inspect appId, appName, webDir, plugins, and absence of server.url.

Create .env.android:

.env.android
VITE_DATA_MODE=demo

This value is public application configuration. It is safe to include in built JavaScript. A password, token, private URL, certificate, or signing credential is not.

Add an Android build script while preserving the normal build:

package.json — build scripts
{
"scripts": {
"build": "npm run typecheck && vite build",
"build:android": "npm run typecheck && vite build --mode android"
}
}

Vite loads .env.android only for --mode android. The ordinary production mode has no VITE_DATA_MODE and must default to API behavior.

Create src/runtime/runtime-mode.ts:

src/runtime/runtime-mode.ts
export type RuntimeMode = 'api' | 'demo';
export function parseRuntimeMode(value: unknown): RuntimeMode {
if (value === undefined || value === 'api') {
return 'api';
}
if (value === 'demo') {
return 'demo';
}
throw new Error('VITE_DATA_MODE must be api, demo, or absent.');
}

An invalid public configuration stops startup instead of silently selecting a data source. The error contains no environment dump or secret.

Add focused tests:

src/runtime/runtime-mode.test.ts
import { describe, expect, it } from 'vitest';
import { parseRuntimeMode } from './runtime-mode';
describe('parseRuntimeMode', () => {
it('uses API mode when the value is absent', () => {
expect(parseRuntimeMode(undefined)).toBe('api');
});
it('accepts the packaged demonstration mode', () => {
expect(parseRuntimeMode('demo')).toBe('demo');
});
it('rejects an unsupported mode', () => {
expect(() => parseRuntimeMode('local')).toThrow(
'VITE_DATA_MODE must be api, demo, or absent.',
);
});
});

Run this file, then the complete suite. Do not read process.env in browser source. Vite exposes client build values through import.meta.env.

The Android package needs useful behavior when no public API is available. It does not need to pretend that memory is a server.

Create src/data/demo-work-items.ts. Use at least six fictional records so the installed application has more than one status, owner, priority, and label. Do not copy student information, school credentials, or live operational data.

src/data/demo-work-items.ts — abbreviated example
import type { WorkItem } from '../types/work-item';
export const demoWorkItems = [
{
id: 'demo-accessible-menu',
title: 'Verify the keyboard menu',
description: 'Check focus order and visible focus in the delivery preview.',
status: 'in-progress',
priority: 'high',
owner: 'Alex Morgan',
labels: ['accessibility', 'client'],
},
{
id: 'demo-api-contract',
title: 'Review the work-item contract',
description: 'Compare the client boundary with the accepted API response.',
status: 'todo',
priority: 'medium',
owner: 'Sam Rivera',
labels: ['api', 'review'],
},
{
id: 'demo-release-note',
title: 'Complete the release note',
description: 'Record the verified web and Android behavior.',
status: 'done',
priority: 'low',
owner: 'Taylor Kim',
labels: ['documentation'],
},
] satisfies WorkItem[];

The abbreviated example shows the shape, not the required record count. Add three more records in the repository. Use the exact WorkItem properties and allowed values from your current contract.

satisfies WorkItem[] checks the complete array and keeps useful literal information. It does not make nested arrays immutable. The adapter will own copies.

Create src/api/demo-work-items-api.ts. It must implement the same WorkItemsApi boundary as the HTTP adapter:

src/api/demo-work-items-api.ts
import { ApiClientError, type WorkItemsApi } from './work-items-api';
import { demoWorkItems } from '../data/demo-work-items';
import type { WorkItem } from '../types/work-item';
function cloneWorkItem(item: WorkItem): WorkItem {
return {
...item,
labels: [...item.labels],
};
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException('The request was aborted.', 'AbortError');
}
}
export function createDemoWorkItemsApi(
seed: readonly WorkItem[] = demoWorkItems,
): WorkItemsApi {
let items = seed.map(cloneWorkItem);
return {
async list(signal) {
throwIfAborted(signal);
return items.map(cloneWorkItem);
},
async updateStatus(itemId, status, signal) {
throwIfAborted(signal);
const existing = items.find((item) => item.id === itemId);
if (!existing) {
throw new ApiClientError(
'contract',
'The demonstration work item was not found.',
);
}
const updated: WorkItem = {
...existing,
status,
};
items = items.map((item) =>
item.id === itemId ? updated : item,
);
return cloneWorkItem(updated);
},
};
}

This adapter has a precise boundary:

  • each factory call owns one copied in-memory collection;
  • list returns new records and new label arrays;
  • updateStatus replaces one owned record;
  • the source seed remains unchanged;
  • reload or Android process termination creates a new adapter and resets the records;
  • there is no network request, disk persistence, cross-device synchronization, or offline queue; and
  • an already-aborted request rejects with the same browser cancellation class used by the client.

Add focused tests with a new adapter in every test:

src/api/demo-work-items-api.test.ts — core cases
import { describe, expect, it } from 'vitest';
import { createDemoWorkItemsApi } from './demo-work-items-api';
import type { WorkItem } from '../types/work-item';
const seed: WorkItem[] = [
{
id: 'item-1',
title: 'Review one item',
description: 'A fictional test record.',
status: 'todo',
priority: 'medium',
owner: 'Test Owner',
labels: ['test'],
},
];
describe('createDemoWorkItemsApi', () => {
it('returns copies instead of its owned values', async () => {
const api = createDemoWorkItemsApi(seed);
const first = await api.list();
const second = await api.list();
expect(first).toEqual(second);
expect(first).not.toBe(second);
expect(first[0]).not.toBe(second[0]);
expect(first[0].labels).not.toBe(second[0].labels);
});
it('updates its own state without changing the seed', async () => {
const api = createDemoWorkItemsApi(seed);
await api.updateStatus('item-1', 'done');
expect((await api.list())[0].status).toBe('done');
expect(seed[0].status).toBe('todo');
});
it('rejects an unknown ID with a bounded client error', async () => {
const api = createDemoWorkItemsApi(seed);
await expect(api.updateStatus('missing', 'done')).rejects.toMatchObject({
kind: 'contract',
message: 'The demonstration work item was not found.',
});
});
it('rejects an already-aborted operation', async () => {
const api = createDemoWorkItemsApi(seed);
const controller = new AbortController();
controller.abort();
await expect(api.list(controller.signal)).rejects.toMatchObject({
name: 'AbortError',
});
});
});

Also test that an update changes only the requested record when the seed contains at least two records. A passing single-record test cannot prove that boundary.

Checkpoint: The demonstration adapter is honest and isolated

What now works
Runtime mode accepts only api or demo, fictional records match the shared contract, the memory adapter implements WorkItemsApi, returned values cannot mutate its owned state, one update remains visible to the same adapter, unknown IDs and aborts are bounded, and the original seed remains unchanged.
Files changed
.env.android, package.json, src/runtime/runtime-mode.ts, src/runtime/runtime-mode.test.ts, src/data/demo-work-items.ts, src/api/demo-work-items-api.ts, src/api/demo-work-items-api.test.ts
What remains
Select the adapter once at startup, expose the demonstration boundary in the interface, and add System Bars safe-area fallback variables.
Next action
Create one runtime factory, pass its selected API and mode into App, and render a persistent demonstration-data notice.
If it does not work
Run the smallest failing test, create a fresh adapter for each case, inspect every mutation boundary, and compare the demo function signatures with WorkItemsApi instead of duplicating the contract.

Create src/runtime/create-runtime.ts:

src/runtime/create-runtime.ts
import { createDemoWorkItemsApi } from '../api/demo-work-items-api';
import { workItemsApi, type WorkItemsApi } from '../api/work-items-api';
import { parseRuntimeMode, type RuntimeMode } from './runtime-mode';
export type RuntimeSelection = {
mode: RuntimeMode;
api: WorkItemsApi;
};
export function createRuntime(
value: unknown = import.meta.env.VITE_DATA_MODE,
): RuntimeSelection {
const mode = parseRuntimeMode(value);
return {
mode,
api: mode === 'demo' ? createDemoWorkItemsApi() : workItemsApi,
};
}

Call the factory once in main.tsx, outside React rendering:

src/main.tsx — relevant structure
const runtime = createRuntime();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App api={runtime.api} runtimeMode={runtime.mode} />
</BrowserRouter>
</StrictMode>,
);

One startup selection prevents a rerender from creating a fresh memory adapter and erasing a status change.

Extend the existing App dependency boundary:

src/App.tsx — data-mode boundary
import type { WorkItemsApi } from './api/work-items-api';
import { workItemsApi } from './api/work-items-api';
import type { RuntimeMode } from './runtime/runtime-mode';
type AppProps = {
api?: WorkItemsApi;
runtimeMode?: RuntimeMode;
};
export function App({
api = workItemsApi,
runtimeMode = 'api',
}: AppProps) {
return (
<div className="app-shell">
{/* Keep the existing semantic header and routes. */}
{runtimeMode === 'demo' ? (
<p className="data-mode-notice">
<strong>Demonstration data.</strong>{' '}
Changes stay in memory and reset when the app reloads or Android
ends the app process.
</p>
) : null}
{/* Pass api to the existing route content. */}
</div>
);
}

Place the notice after the product introduction and before controls that change records. It must remain visible on list and detail routes. Do not hide it in an About page, tooltip, dialog, or color-only badge.

Style it with existing tokens, a border, readable text, wrapping, and no fixed height:

src/styles/_app.scss — example notice
.data-mode-notice {
margin: 0;
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-medium);
background: var(--color-surface-raised);
color: var(--color-text);
overflow-wrap: anywhere;
}

Use token names that exist in your project. Keep the words Demonstration data and the reset statement even if the visual values differ.

Add component tests that render the app with injected dependencies:

src/App.test.tsx — mode notice cases
it('shows the data boundary in demonstration mode', async () => {
renderApp({
api: createDemoWorkItemsApi(),
runtimeMode: 'demo',
});
expect(
await screen.findByText(/changes stay in memory/i),
).toBeVisible();
});
it('does not label the normal API build as demonstration data', async () => {
renderApp({ api: successfulApi, runtimeMode: 'api' });
expect(screen.queryByText(/demonstration data/i)).not.toBeInTheDocument();
});

Adapt renderApp to the helper that already wraps the router. Do not make tests depend on import.meta.env; inject the mode and adapter.

The design lesson used browser safe-area variables. Extend those custom properties so Android can use Capacitor’s injected values:

src/styles/_app.scss — web and Android safe areas
:root {
--app-safe-top: var(
--safe-area-inset-top,
env(safe-area-inset-top, 0px)
);
--app-safe-right: var(
--safe-area-inset-right,
env(safe-area-inset-right, 0px)
);
--app-safe-bottom: var(
--safe-area-inset-bottom,
env(safe-area-inset-bottom, 0px)
);
--app-safe-left: var(
--safe-area-inset-left,
env(safe-area-inset-left, 0px)
);
}

The order is deliberate:

  1. use Capacitor’s injected --safe-area-inset-* value when it exists;
  2. otherwise use the browser env() safe-area value; and
  3. otherwise use zero.

Apply each --app-safe-* property at one outer layout boundary. Do not add the inset to both the shell and every child. Record this change in docs/cross-platform-design.md.

Run the complete client gate. Then build and preview both modes before native generation:

Compare the two web builds
npm run build
npm run preview
# Stop only the preview process that you started.
npm run build:android
npm run preview

The normal build needs the Express API and does not show the notice. The Android build can run without Express, shows the notice, lists the fictional records, changes one status, preserves that change while navigating, and resets it after a reload.

Each Vite build replaces dist. dist is generated evidence, not source. Before native sync, build Android mode again.

Checkpoint: Web and Android modes are distinguishable before packaging

What now works
The normal build selects the HTTP adapter and has no demo notice. The Android build selects a fresh memory adapter, shows the persistent notice, works without Express, and resets after reload. Both use one React application and the shared WorkItemsApi contract.
Files changed
src/runtime/create-runtime.ts, src/main.tsx, src/App.tsx, src/App.test.tsx, src/styles/_app.scss, docs/cross-platform-design.md
What remains
Generate the Android project once, record source ownership, sync a current Android build, and install it on a real native target.
Next action
Build Android mode, run npx cap add android once, and inspect every new tracked and ignored path before adding scripts or opening Android Studio.
If it does not work
Build each mode separately, inspect the generated bundle only for public mode values, check that createRuntime runs once, and confirm that Express is stopped during the Android-mode preview.

Build the correct assets, then add the platform:

Generate the native Android project
npm run build:android
npx cap add android
git status --short

Do not run npx cap add android again after the android/ directory exists. Future changes use sync.

Inspect the generated structure before editing it:

  • Directoryandroid/
    • Directoryapp/
      • Directorysrc/
        • Directorymain/
          • AndroidManifest.xml
          • Directoryjava/
            • Directorydk/
              • Directoryaspit/
                • Directorydeliveryboard/
                  • MainActivity.java
          • Directoryres/
            • …
    • Directorygradle/
      • Directorywrapper/
        • …
    • build.gradle
    • gradlew
    • gradlew.bat
    • settings.gradle
    • variables.gradle

The generated native project is source. It contains the Android application shell, resource ownership, Gradle configuration, and wrapper needed by another developer.

Generated and local outputs have different owners:

Path or value Owner Commit?
android/app/src/main/ native source and resources Project source Yes
Gradle wrapper and checked-in Gradle configuration Project source Yes
android/.gitignore and project settings intended for the team Project source Yes
android/app/src/main/assets/public/ copied web build Capacitor sync No, when the generated ignore rules exclude it
android/**/build/ and .gradle/ Gradle No
android/local.properties Local Android SDK No
.idea/, machine paths, emulator state, logs, and caches Local tools No
dist/ and node_modules/ npm and Vite No
keystores, passwords, tokens, and signing configuration Release owner Never in this lesson

Keep the generated ignore rules unless a reviewed project requirement changes the ownership boundary. Do not force-add ignored copied assets or local.properties to make a clean clone look ready without a build.

Open MainActivity.java. The standard generated class extends BridgeActivity; it should not contain application logic for this lesson.

Open AndroidManifest.xml. Record the package-owned activity and the default network permission created by the template. The demonstration build must still make no network request. A permission states what Android allows; it does not prove that the app uses a network.

Do not add storage, location, camera, microphone, contacts, notification, or background permissions. Delivery Board has no matching product requirement.

Add one repeatable build-and-sync workflow

Section titled “Add one repeatable build-and-sync workflow”

Add these scripts after the platform exists:

package.json — Android workflow
{
"scripts": {
"build:android": "npm run typecheck && vite build --mode android",
"cap:sync:android": "npm run build:android && cap sync android",
"cap:open:android": "cap open android",
"cap:run:android": "npm run cap:sync:android && cap run android --no-sync"
}
}

The order is part of correctness:

  1. build:android validates TypeScript and creates demonstration-mode web assets in dist.
  2. cap sync android copies those assets and updates native plugin dependencies.
  3. cap run android --no-sync builds, installs, and launches the already-synced native project.

Capacitor’s sync command combines copy and update. The explicit build before sync prevents an old dist directory from becoming the installed application. --no-sync prevents the final run command from hiding an extra sync with unclear input.

Run and inspect the sync:

Build and sync current Android assets
npm run cap:sync:android
git status --short

Read the command output. Confirm that the Android platform and App plugin are found and that web assets are copied from dist. If the output refers to another web directory, stop and inspect capacitor.config.ts.

After a new React build, always sync again before native evidence. After only a Java or Android resource change, a web rebuild is not required.

List available targets:

Inspect Android targets
npx cap run android --list

Start the recorded emulator or connect an authorized device. Then use one path.

Build, sync, install, and launch
npm run cap:run:android

Select the recorded target if the CLI asks. Save the exact target name and API version in the evidence record.

Open the generated native project
npm run cap:sync:android
npm run cap:open:android

Wait for Gradle sync to complete. Select the approved emulator or device. Use Run for the app module. Do not use Build → Generate Signed Bundle / APK; release signing and store delivery are outside this lesson.

If Gradle fails, preserve the first complete diagnostic. Check the recorded Android Studio, SDK, JDK owner, network availability for dependency resolution, and generated Gradle versions. Do not edit Gradle versions in several files until the current Capacitor Android requirements identify a mismatch.

Prove that copied assets are self-contained

Section titled “Prove that copied assets are self-contained”

After installation:

  1. stop the Vite preview that you started;
  2. stop the local Express process used for API checks;
  3. keep the emulator or device running;
  4. fully close and relaunch Delivery Board from the Android launcher; and
  5. verify that fictional records and the demonstration notice still appear.

If the installed app requires a terminal process, it is not using the required packaged boundary.

Debug builds expose WebView content for inspection. On the development computer, open Chrome and go to chrome://inspect/#devices. Find the Delivery Board WebView and select inspect.

Record:

  • the current route and document title;
  • WebView or Chrome version;
  • visible Console errors and warnings;
  • failed Network requests;
  • any request whose URL contains /api, 127.0.0.1, localhost as a remote host, or a private development-machine address; and
  • whether the loaded document comes from the installed Capacitor application.

The packaged demo must have no API request. Local Capacitor application URLs can contain localhost as the internal origin. That internal origin is not a remote Express host. Judge the request by scheme, initiator, path, and response rather than searching one word without context.

Use the Network tab while listing records and changing status. A memory operation produces no fetch request. Use the Console after every route and orientation change.

Run this route sequence:

  1. Launch on the work-item list.
  2. Open a detail route through a real link.
  3. Change the status and wait for the confirmed result.
  4. Use Android system Back once.
  5. Confirm that the app returns to the previous list state.
  6. Open a second detail route.
  7. Use the in-app Back link and confirm the same navigation result.
  8. Return to the root route.
  9. Use Android system Back at the root and record whether the platform leaves, minimizes, or closes the activity.
  10. Relaunch and confirm that no Back loop or trapped blank screen exists.

Do not add a custom App backButton listener to make the evidence look different. The default handler uses WebView history when it can and exits at the root according to platform behavior. If the product later requires a confirmation or drawer policy, that becomes a separate feature with route and root tests.

The App plugin maps native activity changes to application lifecycle behavior. The lesson does not require a custom lifecycle listener, but it does require observed behavior.

Run these distinct checks:

Action Expected demonstration result What it proves
Change one status and navigate away and back Updated status remains One adapter instance owns state during the running session
Press Home, wait, and resume while the process survives Current route and status normally remain The current WebView instance survived background and resume
Reload the inspected WebView Seed records return Browser reload creates the startup runtime again
Fully terminate or force-stop the app, then relaunch Seed records return Memory is not persistence
Android terminates the process while backgrounded, then the app relaunches Seed records return Process lifetime, not the UI label, owns memory

Record observed results. Android can end a background process when it needs resources, so do not promise that in-memory state survives every background period.

Extend docs/cross-platform-design.md with a native matrix. Use actual target names and result notes instead of copying this table unchanged.

Context Required checks Result Evidence
Android portrait system bars, top and bottom inset, long list, controls, no horizontal scroll Pass / Blocked screenshot plus target and API
Android landscape inset changes, header wrapping, detail actions, no covered content Pass / Blocked screenshot plus dimensions
WebView zoom 200% or equivalent pinch zoom, reflow, access to all controls Pass / Blocked screenshot and observed scale
Android large font text wrapping, targets, form labels, no clipped notice Pass / Blocked screenshot plus font setting
Touch 2.75-rem controls, spacing, no hover-only action Pass / Blocked interaction notes
Accessibility names, headings, focus where visible, status feedback Pass / Blocked TalkBack or inspector notes
Routes and Back list, detail, in-app Back, system Back, root behavior Pass / Blocked route sequence
Lifecycle Home, resume, reload, terminate, relaunch Pass / Blocked state sequence
Data boundary demo notice, no API fetch, reset statement Pass / Blocked UI and Network evidence
WebView diagnostics Console and Network clean or known issue recorded Pass / Blocked inspected version and notes

A blocked row remains blocked. Name the missing emulator feature, device permission, assistive technology, inspection access, or environment. Do not replace native evidence with the responsive-browser matrix.

If Delivery Board supports light and dark themes, verify both against the system bars. style: 'DEFAULT' lets the platform select an appropriate icon appearance, but your observed contrast is still the evidence. Check content behind every inset in portrait and landscape.

At a minimum, capture:

  • portrait list with the demonstration notice;
  • portrait detail after a confirmed status change;
  • landscape long-content result;
  • zoomed or large-font result;
  • WebView Console and Network result; and
  • relaunch result after process termination.

Do not include private device identifiers, student accounts, notifications, other applications, or unrelated desktop content in screenshots.

Checkpoint: An installed Android result has native evidence

What now works
The generated native project is committed source, build-and-sync order is repeatable, the app installs from current demonstration assets, launches without development servers, makes no API request, preserves default Back behavior, respects system bars and safe areas, and has recorded orientation, zoom, accessibility, lifecycle, reset, Console, and Network results.
Files changed
android/, package.json, capacitor.config.ts, docs/cross-platform-design.md
What remains
Re-run the normal web product, document clean-clone recovery and limits, review the full diff, and make one focused packaging commit.
Next action
Build normal API mode again, run the Express-backed browser matrix, then update README with both workflows and the generated-file policy.
If it does not work
Identify the earliest failing evidence layer: web test, Android-mode Vite build, sync, Gradle build, install, launch, WebView render, or interaction. Rebuild from that layer and preserve its first exact diagnostic.

The last Android build left demonstration assets in dist. Rebuild normal mode before web regression evidence:

Run the final web and Android gates
npm test
npm run typecheck
npm run lint
npm run build
# Start the accepted Express and Vite preview processes.
# Repeat the desktop and narrow browser matrices.
# Stop only processes that you started.
npm run cap:sync:android

Notice the final command builds Android mode again before sync because the script owns that order. The source does not change when dist changes mode.

The normal web regression must prove:

  • no demonstration notice;
  • relative /api requests reach the Express server;
  • validated list and detail responses still render;
  • status updates still use the API contract;
  • loading, empty, error, pending, failure, and success states remain reachable;
  • direct route entry and not-found behavior still work; and
  • desktop, narrow viewport, keyboard, 200% zoom, and automated checks still pass.

Do not use the Android adapter to make a server regression pass.

Update README with these sections:

  • npm run build creates the normal web client and expects the Express /api at runtime.
  • npm run build:android creates a visibly labeled fictional, memory-only demonstration client.
  • Android demonstration changes reset on reload or process termination.
  • Demonstration behavior is not API, persistence, synchronization, offline, database, or public-deployment evidence.

Record the checked versions of Node, npm, Capacitor packages, Android Studio, SDK API, emulator or device, and WebView. Link the current Capacitor environment page.

Explain:

Terminal window
npm run cap:sync:android
npm run cap:run:android
npm run cap:open:android

State which command builds, copies, updates plugins, opens Android Studio, builds native source, installs, and launches.

A second developer must be able to:

  1. install the recorded Node and Android prerequisites;
  2. clone and switch to the feature branch;
  3. run npm ci;
  4. run all web checks;
  5. start the generated Android project from source;
  6. run npm run cap:sync:android; and
  7. install the debug app on an approved target.

dist, copied assets, local.properties, caches, and native build outputs must be reproduced, not fetched from Git.

State that the required result has:

  • Android debug evidence on the named emulator or device;
  • no iOS claim;
  • no public API deployment;
  • no persistent Android data;
  • no offline synchronization;
  • no release signing or store delivery; and
  • no physical-device claim unless a physical device was used.

Update docs/cross-platform-design.md with the final matrices, exact checks, blocked rows, known issues, and the current commit. Avoid phrases such as “mobile ready” when the evidence covers one Android target.

Run:

Review source ownership and scope
git status --short
git diff --check
git diff --stat
git diff -- .env.android capacitor.config.ts package.json package-lock.json
git diff -- src android docs README.md

Before staging, confirm:

  • the normal web and Android data modes cannot be confused;
  • no secret or private URL appears in source, config, native files, screenshots, or logs;
  • no server.url, cleartext exception, broad navigation allowlist, or new Android permission was added;
  • no custom Back listener exists;
  • native source and the Gradle wrapper are included;
  • generated web copies, local SDK paths, caches, build output, and signing material are excluded;
  • the current Android build was synced after the last client change;
  • the README command order matches the scripts; and
  • every passing evidence statement names its actual context.

Stage explicit owners:

Stage the Capacitor lesson result
git add .env.android capacitor.config.ts package.json package-lock.json
git add src android docs README.md
git diff --cached --check
git diff --cached --stat
git status --short

Do not use git add . until you can explain every generated native path. Review staged files and commit one focused packaging result:

Commit the packaged Android application
git commit -m "feat: package Android app with Capacitor"
git status

The clean feature branch is the safe stopping point. The final project assignment will use the accepted web, API, and Android evidence without changing this lesson commit.

Self-check

Complete these checks against the required result.

  1. I recorded actual Node, npm, Capacitor, Android Studio, SDK, target, WebView, branch, and commit versions.
  2. All Capacitor packages use the same supported major version.
  3. The typed config uses the stable application ID, Delivery Board name, and dist web directory.
  4. The config enables WebView zoom, visible system bars, CSS inset handling, debug-only logging, and default App Back handling.
  5. The config has no server URL, development host, cleartext exception, secret, or signing value.
  6. Normal build selects the HTTP API and Android build selects fictional demonstration data.
  7. An absent or api mode is accepted, demo is accepted, and every other mode fails with a bounded error.
  8. The demonstration adapter owns copied seed records and returns copied records and label arrays.
  9. One status update changes only the requested owned record.
  10. Unknown IDs and already-aborted operations have tested bounded failures.
  11. The demonstration notice is persistent, text-based, and states memory and reset behavior.
  12. API mode does not show the demonstration notice.
  13. The runtime factory runs once at startup and App receives the selected dependency.
  14. Safe-area custom properties use Capacitor values, browser env values, and zero in that order.
  15. Safe-area padding is applied at one outer layout boundary.
  16. The Android project was generated once from reviewed configuration.
  17. Native source, resources, Gradle configuration, and wrapper are staged as project source.
  18. Copied assets, dist, build directories, caches, local SDK paths, and signing material remain excluded.
  19. The required script order is Android Vite build, Capacitor sync, native build, install, and launch.
  20. The installed app launches after Vite and Express are stopped.
  21. The Android result shows the demonstration notice and makes no API or development-host request.
  22. List, detail, filters, confirmed status update, loading, and feedback behavior work on Android.
  23. Android system Back returns from detail and has recorded root behavior without a custom listener.
  24. System bars and safe areas were checked in portrait and landscape.
  25. WebView zoom or equivalent and Android large-font behavior were checked.
  26. Touch targets, accessible names, headings, focus where available, and status feedback were checked.
  27. Background, resume, reload, termination, and relaunch results were recorded separately.
  28. WebView Console and Network evidence names the inspected target and version.
  29. A normal Express-backed web build still passes after the Android work.
  30. Desktop, narrow, keyboard, and 200% web evidence still pass.
  31. All tests, typecheck, lint, normal build, and Android build pass.
  32. README explains runtime modes, commands, clean-clone recovery, generated ownership, and current limits.
  33. The design document contains actual native results and keeps blocked rows visible.
  34. No iOS, persistence, offline synchronization, public deployment, store, signing, or untested-device claim was added.
  35. The staged diff contains one focused Capacitor packaging result and the branch is clean after commit.

Capacitor does not convert React into Java or replace Vite. Vite still checks and builds the TypeScript and React application. Capacitor copies that web output into a native Android project. Gradle builds the Android shell. Android installs the package. WebView renders the copied web application. Plugins and the bridge provide selected native integration.

The data mode solves a deployment boundary, not an architecture puzzle. The browser product continues to prove client-server communication through Express. The Android debug package proves that the shared interface can run inside a native shell. Its explicit memory adapter lets the interface remain useful without teaching a hidden development-host dependency as deployment.

The shared WorkItemsApi contract keeps the choice narrow. React components use one typed capability. Startup selects either the HTTP implementation or the copied-memory implementation. Tests inject either implementation without changing environment state.

The generated Android project is not a disposable export. It contains source and build configuration that belongs in Git. The copied web bundle and Gradle output remain reproducible products of commands. That ownership distinction makes a clean clone meaningful.

Native evidence adds facts that responsive browser evidence cannot provide: Android installation, WebView rendering, system bars, system Back, app lifecycle, Gradle build, and operation without development servers. Browser evidence still owns its facts. Neither result replaces the other.

Check the current documentation before using version-specific commands or requirements:

Complete the required result and its focused commit before choosing an extension.