AI Packs (Google Play delivery)¶
Overview¶
Audience: app teams consuming the TheStage Android SDK who want model bundles delivered by Google Play (Play for On-device AI / Play Asset Delivery) instead of — or in addition to — the default Hugging Face download.
What you get: Play hosts the models for free inside your AAB, pre-downloads
them right after install (fast-follow) or on first use (on-demand), serves each
phone only the variant matching its SoC, and delta-patches across app updates.
The SDK API is unchanged — only the engines_path string differs.
Choose the Source per Model¶
Hugging Face (default) |
AI pack |
|
|---|---|---|
Model update |
Anytime, no app release |
Rides your app release |
Hosting |
TheStage HF repos |
Google Play (free) |
First download |
On first |
At install (fast-follow) or first use |
Works on sideloads / non-Play stores |
yes |
no (use fallback) |
SoC variant selection |
SDK runtime detection |
Play device catalog |
Both can be mixed in one app, per model. An AI-pack model can also declare an HF fallback so sideloaded installs still work (see Start the Model below).
Decision matrix per model:
Large NPU model (100 MB+) → AI pack,
on-demand(+ optional first-launchprefetch_model), orfast-follow+aipack_keep_pack: truewhen the model must be ready in the first seconds after install.Small CPU-only model (a few MB — silero-vad, smart-turn, …) → skip packs entirely: bundle the
engines.zipas an ordinary app asset, copy it once to app storage at first run, and pass that path asengines_path(a local path is a first-class source). Flutter:Future<String> stageAsset(String asset, String name) async { final dir = await getApplicationSupportDirectory(); final f = File('${dir.path}/$name'); if (!f.existsSync()) { final bytes = await rootBundle.load(asset); await f.writeAsBytes(bytes.buffer.asUint8List()); } return f.path; } // engines_path: await stageAsset('assets/vad_engines.zip', // 'vad_engines.zip')
Model whose updates must NOT wait for app releases → keep it on Hugging Face.
Prerequisites¶
Android Gradle Plugin 8.10+ (device targeting needs it).
gradle.properties:android.experimental.enableDeviceTargetingConfigApi=trueRequired for NPU models — AAB installs keep native libs compressed by default, which breaks the Qualcomm DSP loader and silently drops inference to CPU. In
app/build.gradle:android { packaging { jniLibs { useLegacyPackaging = true } } }
Flutter apps: nothing else — the
thestage_android_sdkplugin carries thecom.google.android.play:ai-deliverydependency transitively. Native-AAR integrations add it themselves:implementation "com.google.android.play:ai-delivery:0.1.1-alpha01"App must ship as an AAB through Play (packs never work in a plain APK — sideloads take the fallback path).
Get the Model Files¶
For each model you license, TheStage provides the same per-SoC bundles that back the HF path:
engines_qualcomm_sm8550.zip (Snapdragon 8 Gen 2)
engines_qualcomm_sm8650.zip (Snapdragon 8 Gen 3)
engines_qualcomm_sm8750.zip (Snapdragon 8 Elite)
engines_qualcomm_sm8850.zip (Snapdragon 8 Elite Gen 5)
engines_cpu.zip (fallback, if available)
Each release comes with a release tag (e.g. whisper-0.2.3). You will
need it in Start the Model below.
Create One AI Pack Module per Model¶
Layout (example: whisper):
your-app/
├── settings.gradle(.kts) # include(":thestageai_models_whisper")
├── app/
└── thestageai_models_whisper/
├── build.gradle.kts
└── src/main/assets/
├── thestageai_models_whisper#group_sm8550/engines.zip
├── thestageai_models_whisper#group_sm8650/engines.zip
├── thestageai_models_whisper#group_sm8750/engines.zip
├── thestageai_models_whisper#group_sm8850/engines.zip
└── thestageai_models_whisper#group_other/engines.zip
Drop each per-SoC zip into its <packName>#group_<soc>/ directory under the
name engines.zip. The asset dir is named after the pack — required as
soon as the AAB carries more than one pack: packs are built fusing-enabled, so
bundletool rejects identical bare paths across modules (EntryClash). One
naming rule for every delivery mode. The #group_ suffix is Play routing
metadata — it is stripped on delivery, so at runtime the device sees
<packName>/engines.zip containing its own variant. The SDK routes nothing
here; Play’s device catalog does.
thestageai_models_whisper/build.gradle.kts:
plugins { id("com.android.ai-pack") }
aiPack {
packName.set("thestageai_models_whisper")
dynamicDelivery {
// "fast-follow": auto-downloads right after install.
// "on-demand": downloads on first start_model.
// "install-time": small packs only — see the
// install-time note under "Start the Model"; use
// assets/<packName>#group_<soc>/ as the asset dir
// so multiple install-time packs don't collide in
// the merged asset namespace.
deliveryType.set("on-demand")
}
}
Install-Time Packs (Small Models)¶
For a small model that must be present from the very first launch (no download, works offline immediately), use an install-time pack. Two differences from the on-demand module above: the delivery type, and the asset dir is named after the pack (REQUIRED — install-time assets merge into one shared namespace, and the pack-named dir keeps multiple packs from colliding):
thestageai_models_vad/
├── build.gradle.kts
└── src/main/assets/
├── thestageai_models_vad#group_sm8550/engines.zip
├── thestageai_models_vad#group_sm8650/engines.zip
├── thestageai_models_vad#group_sm8750/engines.zip
├── thestageai_models_vad#group_sm8850/engines.zip
└── thestageai_models_vad#group_other/engines.zip
(One SoC-independent bundle? Use a single unsuffixed dir
thestageai_models_vad/engines.zip — no groups needed.)
aiPack {
packName.set("thestageai_models_vad")
dynamicDelivery {
deliveryType.set("install-time")
}
}
Start it exactly like any other source — the SDK detects the install-time pack and stages it from the APK automatically:
await TheStageFlutterSDK.start_model(
model_type: 'silero-vad',
model_name: 'silero_vad',
engines_path: 'aipack://thestageai_models_vad',
config: {'aipack_release_tag': 'vad-1.0.0'},
);
aipack_keep_pack / aipack_fallback_repo are meaningless here (the pack
is part of the APK and present on every install, including sideloads of a
universal APK). The release tag works the same as in Start the Model: bump
it only when the model files change, and app updates cost zero re-staging
otherwise. Size discipline: the pack is a permanent part of the install — keep
it to tens of MB.
Wire the App Module¶
app/build.gradle.kts:
android {
assetPacks += listOf(
":thestageai_models_whisper",
// ...one entry per model pack
)
bundle {
deviceTargetingConfig =
file("device_targeting_config.xml")
deviceGroup {
enableSplit = true
defaultGroup = "other"
}
}
}
Before locking the config, enumerate the exact SoC strings in Play Console’s
Device Catalog (searchable by SoC): matching is exact — no wildcards — so
binned variants (SM8650-AC etc.) each need their own selector line
alongside the base part number.
app/device_targeting_config.xml (shared by all packs; both manufacturer
spellings are required — devices report either):
<config:device-targeting-config
xmlns:config="http://schemas.android.com/apk/config">
<config:device-group name="sm8550">
<config:device-selector>
<config:system-on-chip manufacturer="QTI" model="SM8550"/>
</config:device-selector>
<config:device-selector>
<config:system-on-chip manufacturer="Qualcomm" model="SM8550"/>
</config:device-selector>
</config:device-group>
<!-- repeat for sm8650 / sm8750 / sm8850 -->
</config:device-targeting-config>
Start the Model¶
Identical to the HF flow except for engines_path:
await TheStageFlutterSDK.start_model(
model_type: 'whisper',
model_name: 'whisper',
engines_path: 'aipack://thestageai_models_whisper',
config: {
// Release tag of the bundles inside the pack. Bump it
// when (and only when) you ship new model files; an
// app update with an unchanged tag reuses the already
// extracted model with zero downloads.
'aipack_release_tag': 'whisper-0.2.3',
// true -> keep Play's copy after extraction.
// REQUIRED for fast-follow packs.
// false -> release it (default; on-demand packs only).
'aipack_keep_pack': false,
// Optional: sideloads / unmatched devices / fetch
// failures fall back to the normal HF download.
'aipack_fallback_repo':
'TheStageAI/thewhisper-large-v3-turbo',
},
);
Download progress arrives on the same progress stream as HF downloads (0..1 fraction).
Rules of thumb:
on-demand+aipack_keep_pack: false— default. 1x disk; the Play copy is released after extraction; re-downloads happen only whenaipack_release_tagchanges.fast-follow+aipack_keep_pack: true— best first-open UX (model is on the phone before first launch), costs 2x disk.
Why fast-follow requires keep_pack: true: fast-follow is a declarative
contract — “Play, keep this pack present on the device”. Play honors it not
just at install but at every app update: if the pack is missing at update time
(because the SDK removed it), Play re-syncs to the declared state and
re-downloads the whole pack — even when its content did not change. Removing a
fast-follow pack therefore buys nothing (Play restores it) and costs a full
pack download on every update (verified on a Play internal test track). With
keep_pack: true the kept copy is delta-patched across updates instead. If
the 2x disk bothers you, the right lever is switching the pack to
on-demand, where removal sticks — not removing a fast-follow pack.
Fast-follow UX at 1x disk: the only thing fast-follow adds over on-demand is
that the download starts between install and first open. You can get within a
hair of that with on-demand + calling
prefetch_model(repo_id: 'aipack://...') fire-and-forget as soon as the app
first starts (behind your onboarding/splash flow). The model downloads while
the user is still getting set up, removal sticks (1x disk), and updates cost
zero while the release tag is unchanged. The residual gap: the download cannot
begin until the first app open with network, so a user who reaches the model
feature within the first seconds of the very first run may still see the
download progress. Pick fast-follow + keep only if that gap is unacceptable.
install-time — small packs only. Install-time packs are fused into the
installed APK set: the bytes become a permanent part of the app image
(removePack() doesn’t apply — 2x disk forever), they count against the
4 GB base-app cap, inflate the store-listed install size, and delay install
completion. For 100 MB+ models that’s disqualifying — use on-demand or
fast-follow. For a SMALL per-SoC model (tens of MB) it’s a fine trade:
guaranteed presence from the very first launch, no fetch, no network
dependency. The SDK handles install-time packs transparently (same
aipack:// path — it stages the archive from the APK’s assets once per
release tag). Keep the pack’s asset dir named after the pack
(<packName>#group_<soc>/engines.zip) so several install-time packs can
coexist — the pack-named dir is REQUIRED for install-time packs (the bare
engines/ layout is only for on-demand/fast-follow, whose files arrive at a
private per-pack location). Small models with NO per-SoC variants don’t need a
pack at all — see the decision matrix in Choose the Source per Model.
Large Downloads off Wi-Fi / Metered Networks¶
Play schedules pack downloads as unmetered-only by default, so a fetch can park instead of running:
On cellular, a >200 MB download needs the user’s OK to use mobile data. Play surfaces a system confirmation in the notification shade; until the user taps it, the fetch waits.
On a Wi-Fi network flagged as metered (phone hotspots, some corporate networks), the fetch waits for an unmetered network.
The SDK surfaces both as a status on the progress stream so your app can
react instead of showing a spinner that looks hung (progress goes negative
and should be ignored while status is set):
TheStageFlutterSDK.on_progress.listen((e) {
switch (e['status']) {
case 'waiting_for_confirmation':
// >200 MB on cellular — show Play's own dialog (below).
_promptCellularConfirmation();
case 'waiting_for_wifi':
// Ask the user to connect to Wi-Fi.
_showWaitingForWifi();
default:
_showProgress(e['progress'] as double); // 0..1
}
// A waiting event carries `status` and no `progress`; a
// progress event carries `progress` and no `status`.
});
Showing Play’s cellular confirmation dialog¶
The confirmation dialog is an Activity UI flow, so trigger it from your app (not the SDK). Two setup requirements (both verified on a Flutter app):
Declare the dependency in your app — the plugin ships ai-delivery as
implementation(runtime only), so your app can’t compile againstAiPackManagerunless it declares it too. Inapp/build.gradle(.kts):implementation("com.google.android.play:ai-delivery:0.1.1-alpha01")Use
FlutterFragmentActivity, notFlutterActivity—registerForActivityResult(and thusshowConfirmationDialog(launcher)) needs a ComponentActivity; the defaultFlutterActivityextends plainActivity.
class MainActivity : FlutterFragmentActivity() {
// Registered at Activity creation (a launcher can't be made
// on demand). The paused fetch resumes on approval, so the
// callback body is empty.
private val aiPackConfirm = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { /* fetch resumes automatically */ }
override fun configureFlutterEngine(engine: FlutterEngine) {
super.configureFlutterEngine(engine)
MethodChannel(
engine.dartExecutor.binaryMessenger, "app/aipack"
).setMethodCallHandler { call, result ->
if (call.method == "showConfirmation") {
result.success(
AiPackManagerFactory.getInstance(this)
.showConfirmationDialog(aiPackConfirm)
)
} else result.notImplemented()
}
}
}
Invoke MethodChannel('app/aipack').invokeMethod('showConfirmation') from
the waiting_for_confirmation / waiting_for_wifi branch above. (The
dialog acts on the app’s pending downloads, so it works even though the SDK
started the fetch; no start_model restart needed.)
Alternative: sidestep the whole path by pre-fetching on Wi-Fi at onboarding
(prefetch_model with the aipack:// path) so the model is already
present before the user needs it.
Normal download progress arrives on the same stream (0..1), same as HF downloads — verified end-to-end on a Play internal track.
Availability Check¶
check_model_availability accepts aipack:// paths like any other
source — usable before initialize, no token, no download — and it covers
every delivery type through one query: an install-time pack or a completed
fast-follow / on-demand fetch reports as delivered; a not-yet-delivered
on-demand / fast-follow pack reports as fetchable.
availability is
localwhen the pack is already on the device (install-time, or a completed fetch) — it loads offline. It isremotewhen the pack is fetchable but not yet on the device (an on-demand / fast-follow download still pending); in thatremotecasebundleSizeBytescarries Play’s reported download size (the one thing knowable before the fetch). It isnonewhen the pack is absent or a fetch failed.reason is
aipack_packon any available result (localorremote), andvariant_unavailablewhen the pack is absent / unknown to Play. (network_unreachableis reported for a transient probe error — no Play services on the device, or a service error — meaning “unknown”, not “absent”.)compute is always null for an AI pack. Play does not expose the delivered variant/group to the client — no AssetPackManager / AiPackManager API returns it — so the SDK does not claim a
computeit cannot verify. This is the same contract as any local bundle;computeis only ever populated on the HF path, which probes the exact published variant. If your app needs to know the group before download, that is a curation choice on your side (per-group pack names, or a curator manifest you ship alongside the packs), not something the SDK can probe.
final r = await TheStageFlutterSDK.check_model_availability(
model_path: 'aipack://thestageai_models_whisper',
);
switch (r.availability) {
case ModelAvailability.local:
// Pack already on the device — loads offline.
break;
case ModelAvailability.remote:
// Fetchable; r.bundleSizeBytes is Play's download size.
break;
case ModelAvailability.none:
// Absent or failed — inspect r.reason.
break;
default:
break;
}
// On any available result the reason is aipack_pack (raw wire
// value 'aipack_pack'); compute is null — the SDK can't see the
// delivered SoC group.
final available = r.reason == AvailabilityReason.aipackPack;
Testing¶
Local, no Play account (bundletool 1.18+):
./gradlew :app:bundleRelease
java -jar bundletool.jar build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app.apks --local-testing
java -jar bundletool.jar install-apks --apks=app.apks \
--device-groups=sm8750 # simulate the group match
Local-testing caveats: fast-follow behaves as on-demand, and the real SoC
group matching is bypassed (--device-groups picks it). Install-time packs
install normally in local testing (they’re ordinary split APKs), so both
routes are locally testable. Always finish with one pass on a Play internal
test track — it exercises true fast-follow, device-catalog matching, and
update patching. Internal testing needs no review and no public listing.
Behavior Across App Updates¶
Model files unchanged (same
aipack_release_tag): the SDK keeps serving its extracted copy; Play keeps/patches its pack copy (if kept). Zero model bytes downloaded.Model files changed (new tag): the SDK re-fetches the pack once and re-extracts. With fast-follow the new pack is typically already on the phone before the app first opens.
Never persist the pack’s
assetsPathyourself — it changes every app version. The SDK re-resolves it on each start.
Complete Example (one on-demand model, with dialog)¶
Everything above assembled for a single on-demand model (whisper). Copy
this, then read the sections for detail.
File tree¶
my-app/
├── settings.gradle.kts
├── android/ # (Flutter app's android/)
│ ├── gradle.properties
│ ├── app/
│ │ ├── build.gradle.kts
│ │ ├── device_targeting_config.xml
│ │ └── src/main/kotlin/.../MainActivity.kt
│ └── thestageai_models_whisper/
│ ├── build.gradle.kts
│ └── src/main/assets/
│ ├── thestageai_models_whisper#group_sm8550/engines.zip
│ ├── thestageai_models_whisper#group_sm8650/engines.zip
│ ├── thestageai_models_whisper#group_sm8750/engines.zip
│ ├── thestageai_models_whisper#group_sm8850/engines.zip
│ └── thestageai_models_whisper#group_other/engines.zip
└── lib/main.dart
settings.gradle.kts¶
plugins {
id("com.android.application") version "8.10.1" apply false
id("com.android.ai-pack") version "8.10.1" apply false
}
include(":app", ":thestageai_models_whisper")
gradle.properties¶
android.experimental.enableDeviceTargetingConfigApi=true
thestageai_models_whisper/build.gradle.kts¶
plugins { id("com.android.ai-pack") }
aiPack {
packName.set("thestageai_models_whisper")
dynamicDelivery { deliveryType.set("on-demand") }
}
app/build.gradle.kts (relevant parts)¶
android {
assetPacks += listOf(":thestageai_models_whisper")
bundle {
deviceTargetingConfig = file("device_targeting_config.xml")
deviceGroup { enableSplit = true; defaultGroup = "other" }
}
packaging { jniLibs { useLegacyPackaging = true } }
}
dependencies {
implementation("com.google.android.play:ai-delivery:0.1.1-alpha01")
}
app/device_targeting_config.xml¶
<config:device-targeting-config
xmlns:config="http://schemas.android.com/apk/config">
<config:device-group name="sm8750">
<config:device-selector>
<config:system-on-chip manufacturer="QTI" model="SM8750"/>
</config:device-selector>
<config:device-selector>
<config:system-on-chip manufacturer="Qualcomm" model="SM8750"/>
</config:device-selector>
</config:device-group>
<!-- repeat for sm8550 / sm8650 / sm8850 -->
</config:device-targeting-config>
MainActivity.kt (dialog host)¶
class MainActivity : FlutterFragmentActivity() {
private val confirm = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { /* fetch resumes automatically */ }
override fun configureFlutterEngine(engine: FlutterEngine) {
super.configureFlutterEngine(engine)
MethodChannel(
engine.dartExecutor.binaryMessenger, "app/aipack"
).setMethodCallHandler { call, result ->
if (call.method == "showConfirmation")
result.success(
AiPackManagerFactory.getInstance(this)
.showConfirmationDialog(confirm))
else result.notImplemented()
}
}
}
lib/main.dart (the whole runtime flow)¶
const _channel = MethodChannel('app/aipack');
var _dialogShown = false;
Future<void> loadWhisper() async {
TheStageFlutterSDK.on_progress.listen((e) {
final status = e['status'] as String?;
if (status == 'waiting_for_wifi' ||
status == 'waiting_for_confirmation') {
if (!_dialogShown) {
_dialogShown = true;
_channel.invokeMethod('showConfirmation'); // Play's dialog
}
setDownloadLabel('Confirm mobile-data download…');
} else {
_dialogShown = false;
setDownloadProgress(e['progress'] as double); // 0..1, present
} // on progress events
});
await TheStageFlutterSDK.start_model(
model_type: 'whisper',
model_name: 'whisper',
engines_path: 'aipack://thestageai_models_whisper',
config: {'aipack_release_tag': 'whisper-1.0.0'},
);
// Model ready on the NPU. On cellular: fetch parks → dialog →
// approve → resumes → loads. On Wi-Fi: downloads → loads.
}
That is the entire integration: five short config files, one Activity, and one
Dart function. Adding more models = one more pack module + one more
start_model call.
- AI Packs (Google Play delivery)
- Overview
- Choose the Source per Model
- Prerequisites
- Get the Model Files
- Create One AI Pack Module per Model
- Install-Time Packs (Small Models)
- Wire the App Module
- Start the Model
- Large Downloads off Wi-Fi / Metered Networks
- Availability Check
- Testing
- Behavior Across App Updates
- Complete Example (one on-demand model, with dialog)