Ship one on-device AI feature on your platform — Android ML Kit GenAI, iOS Foundation Models, cross-platform, or Chrome built-in AI for web-only learners — with a fallback path for unsupported devices that you prove actually executes. A convincing demo isn't enough; the fallback branch must be shown running.
Wei is a mobile engineer whose PM wants "summarize this thread with AI, on-device, private, free." The demo works beautifully on Wei's flagship test phone. Then QA runs it on a three-year-old mid-range device with no on-device model support and the feature crashes — because the happy-path demo was the only path anyone built. On-device AI is a capability-detection problem before it's an AI problem: the model may or may not be present, downloading, or supported, and the graceful-degradation path is the actual deliverable. This capstone holds you to proving the fallback runs, not just the feature.
~120 minutes hands-on · Go Mobile stage (maps to module M8, playbook PB-52) · $0 — pick the track matching your platform; the web track needs only Chrome Canary and runs on any machine, so it's the universal fallback if you lack mobile tooling.
Pick one track. All four build the same feature — summarize a text thread on-device with a cloud-or-stub fallback — and all four are graded against the same rubric.
Track W — Chrome built-in AI (web, universal). Chrome's Summarizer/LanguageModel APIs run Gemini Nano locally.
npm create vite@latest ondevice-lab -- --template vanilla-ts
cd ondevice-lab && npm install
# Chrome Canary/Dev with the on-device AI flags enabled (chrome://flags → "Prompt API"/"Summarization API")
src/summarize.ts — the capability-gated core (author the UI around it):
export type SummarizeResult = { text: string; source: "on-device" | "cloud" | "unavailable" };
export async function summarize(thread: string): Promise<SummarizeResult> {
// 1. Capability detection — the heart of the lab.
const canDevice = "Summarizer" in self &&
(await (self as any).Summarizer.availability?.()) !== "unavailable";
if (canDevice) {
const avail = await (self as any).Summarizer.availability();
if (avail === "downloadable" || avail === "downloading") {
// model not ready yet — surface progress, then this call falls through to cloud
return cloudFallback(thread, "model downloading");
}
const summarizer = await (self as any).Summarizer.create({ type: "tldr", length: "short" });
const text = await summarizer.summarize(thread);
return { text, source: "on-device" };
}
return cloudFallback(thread, "no on-device support");
}
async function cloudFallback(thread: string, _why: string): Promise<SummarizeResult> {
// Stub: in production this is your BFF proxy (see Lab 13). For the lab, a deterministic
// extractive summary proves the branch runs without needing a provider key.
const firstLines = thread.split("\n").filter(Boolean).slice(0, 2).join(" ");
return { text: `(cloud) ${firstLines}…`, source: "cloud" };
}
Track A — Android ML Kit GenAI (Kotlin/Compose). Use ML Kit GenAI Summarization; capability check via the feature's checkFeatureStatus/download API; fallback to a cloud call (Firebase AI Logic) or a stub. Scaffold with Android Studio's Gemini/Agent Mode, but you own the fallback logic.
Track I — iOS Foundation Models (Swift/SwiftUI, iOS 26 / Xcode 26). Use the Foundation Models framework; gate on SystemLanguageModel.default.availability (.available vs .unavailable(reason)); fallback to cloud/stub when unavailable (older device, Apple Intelligence off, model not ready).
Track X — Cross-platform (Flutter AI Toolkit or react-native-ai). Detect the on-device provider; degrade to a cloud provider behind App Check when absent.
The evidence ledger, ondevice-report.md:
| Item | Evidence |
|------|----------|
| On-device path runs | (recording timestamp / log line showing source="on-device") |
| Capability detection branches | (the availability values you handled) |
| Fallback path RUNS (not just exists) | (recording/log showing source="cloud" reached) |
| Feature correctness both paths | (summary quality note) |
| Privacy/cost rationale | (why on-device here) |
source ("on-device" / "cloud"). Run on a supported device/browser. Expected: a summary with the on-device badge. This is the easy third.ondevice-report.md, list every availability value your platform exposes — Chrome: unavailable | downloadable | downloading | available; iOS: .available | .unavailable(deviceNotEligible | appleIntelligenceNotEnabled | modelNotReady); Android/ML Kit: feature-status + download states. Each is a branch you must handle. A feature that handles only "available" is the QA-crash from the scenario.downloadable/downloading/modelNotReady: the model could work later but isn't ready now. Design this explicitly — either trigger the download with visible progress and let this request use the fallback, or surface "preparing on-device AI…". Do not treat not-ready as permanently-unavailable, and do not block the user. Log which choice you made and why.?forceUnavailable=1 switch that short-circuits canDevice to false; on iOS, run the older-device simulator or an availability-mocking flag; on Android, a build flag that reports the feature unavailable. Run the feature. Expected: the summary still appears, badged "cloud", and a log line proves cloudFallback executed. Record this — an unexecuted fallback branch counts as not built.proof/."It works on my device" is the on-device AI trap in one sentence. Model availability depends on device tier, OS version, user settings (Apple Intelligence toggle), storage, and download state — none of which your test phone represents. The fallback branch is not a nice-to-have; on the installed base it is the path a large fraction of users will actually hit.
on-device.cloud, with a log line as evidence — existence in code is insufficient.proof/ contains a recording showing both branches in one session.ondevice-report.md has all five ledger rows filled and the privacy/cost rationale.On-device AI is a capability-detection problem wearing an AI costume — the model's presence depends on device tier, OS, settings, and download state your test hardware never represents, so the deliverable isn't the summary, it's the proven-executing fallback path; a degradation branch you haven't watched run is the crash QA is about to find.