Feature SDKs (Pro)
webview_sdk_advanced Pro
- Tier
- Proversion 3.0.5
- Command
archipelago generate webview_sdk_advanced- Generates
devtools/scripts/miniapp_signerfeatures/webview_advanced
- Generates first
- shared_consentwhen needed and missing, so there is nothing to install first
- Depends on
- shared_identifiers, flutter_l10ngenerate these first
- Used by
- app_debugger_miniapp
Self-contained, standalone WebView Advanced feature SDK. Ships its own full WebView capability plus a bidirectional JS bridge (typed handlers + Promises), request/response interceptors for auth header injection, deeplink routing from WebView back to native, and a full mini-app container (manifest registry, bridge dispatcher, lifecycle state machine, capability handlers, and
MiniAppContainerPage).
Independent of webview_sdk
webview_sdk_advanced does not require webview_sdk to be installed first, and does not extend or build on it — generate this brick alone if your app needs the JS bridge and/or mini-app hosting. Installing both is not a supported layering model; pick the one your project needs.
Variables
| Variable | Type | Default | Description |
|---|---|---|---|
uiKitPackageName | string | app_ui_kit | Name of the UI kit package used by the mini-app container. When generating into an existing workspace, pre_gen uses the name recorded in archipelago.yaml (detecting packages/*_ui_kit if it was never recorded) and fails with a clear message if that is ambiguous. |
prefix | string | App | UI kit class prefix, for example Acme for AcmeTheme. pre_gen reads it from archipelago.yaml (project.prefix), so you rarely set it |
Mini-app hosting is not gated behind a variable — it is the point of this brick. If you don't need it, install webview_sdk (the basic, independent brick) instead.
analyticsInstalled is derived automatically (not a prompted variable) from whether infrastructure/analytics_api exists in the target project — when absent, the analytics bridge capability (handler, imports, tracker param) is stripped from the generated sources and the analytics_api dependency is omitted.
Usage
Interactive
archipelago generate webview_sdk_advancedNon-interactive (CI)
archipelago generate webview_sdk_advanced --config my_config.jsonGenerated Package
This brick generates a single, complete, standalone package — it does not patch or inject files into any other package.
features/
└── webview_advanced/
└── lib/src/
├── js_handler.dart # Abstract JsHandler<TArgs, TResult> base
├── js_handler_registry.dart # JsHandlerRegistry (first-registration-wins)
├── webview_request.dart # Immutable WebViewRequest value class
├── webview_request_interceptor.dart # WebViewRequestInterceptor interface
├── webview_advanced_sdk.dart # WebViewAdvancedSDK contract
├── webview_advanced_sdk_impl.dart # flutter_inappwebview-backed impl
├── webview_controller.dart
├── webview_inappwebview_controller.dart
├── webview_inappwebview_controller_advanced.dart
├── di/
├── models/
│ ├── js_message.dart
│ ├── webview_advanced_config.dart # WebViewAdvancedConfig extends WebViewConfig
│ └── ...
└── mini_app/
├── bridge/
├── bridge_dispatcher.dart
├── consent/ # partner consent: planner, controller, sheet, settings pages
├── container/
│ ├── mini_app_container.dart
│ └── mini_app_container_page.dart
├── handlers/ # analytics_handler.dart omitted when analytics_api is absent
├── interceptors/
├── lifecycle/
├── manifest/
├── manifest_v2/ # signed manifests: verifier, rollback store, kill list, scopes
├── providers/
├── registry/
├── tokens/
└── webview_bridge_binding.dartDistinct type names
To avoid ambiguous-import collisions if both webview and webview_advanced ever end up in the same workspace (they are both kept in template_base for analyze/tests), the advanced package's FeatureSDK interface is WebViewAdvancedSDK / WebViewAdvancedSDKImpl — not WebViewSDK. Everything else (WebViewController, WebViewConfig, mini-app types, etc.) is duplicated wholesale rather than shared, per the independence design.
Bridge origin lockdown
Both bridges (window.archipelago.*'s jsHandlers, and the mini-app container's archipelago_bridge) are scoped to the main frame only (javaScriptHandlersForMainFrameOnly: true) and to WebViewAdvancedConfig .trustedOrigins (javaScriptHandlersOriginAllowList) whenever a trust policy is configured. Without this, an attacker-controlled <iframe> embedded in an otherwise-trusted page could invoke the bridge itself — the per-call trusted-domain check alone only ever sees the top-level frame's URL, not a different-origin subframe's. When no trust policy is configured (the default), neither restriction is applied, so unrelated internal JS handlers the plugin itself uses aren't broken for a caller who never set up a bridge at all.
Mini-App Container
The mini-app container — the headline Pro feature — is generated unconditionally for running config-driven web micro-apps inside your native shell.
How it works
- Manifest registry —
MiniAppRegistry(ConfigMiniAppRegistry) loads mini-app definitions fromassets/config/mini_apps.yamlviaArchipelagoConfigHelper, with an optional remote-config source layered on top (capability-guarded for net-new mini-app ids). The post-gen hook registers the asset in the host app'spubspec.yamland writes a starter YAML file. - Bridge dispatcher —
MiniAppBridgeDispatchervalidates (bridge version, per-invoke origin, capability grant) and routes incoming JS calls to registeredBridgeCapabilityHandlerimplementations. - Lifecycle state machine —
MiniAppContainertracksinitializing → loading → ready → closing → closed(with anerrorbranch) and exposes transitions as aMiniAppLifecycleEventstream. - Capability handlers —
LifecycleHandler(always registered),NavigationHandler,ProfileHandler,StorageHandler,PermissionsHandler,AuthHandler,ShareHandler,HapticsHandler, andAnalyticsHandler(only generated whenanalytics_apiis present in the target project). MiniAppContainerPage— A ready-to-use Flutter page that wires up the manifest, bridge dispatcher, and container widget.
Mini-app config file
The post-gen hook creates assets/config/mini_apps.yaml in your app:
mini_apps:
- id: rewards
entry_url: https://rewards.example.com/
allowed_origins:
- https://rewards.example.com
bridge_origins:
- https://rewards.example.com
capabilities: [auth, profile, navigation, analytics]
display:
mode: fullscreen
show_header: true
title: "Rewards"Delivery field
An entry can carry delivery: bundled | onDemand (default bundled) and a component name. onDemand needs a component; an entry without one, or with an unknown delivery, is skipped like any malformed entry.
- id: rewards
delivery: onDemand
component: rewardsIn template_base the host mounts an onDemand Flutter mini-app behind MiniAppDeliveryGate. The gate installs the component through the Pinisi contract (pinisi_api), shows progress and errors, offers Retry for retryable errors, and mounts only after the install finishes. With no Pinisi registered, an onDemand entry shows an error, not a crash. Bundled entries never touch Pinisi.
This brick ships the field and its validation only. MiniAppDeliveryGate and the pinisi_api dependency arrive with the Pinisi brick, so generated projects do not depend on a package no brick provides yet. Until then the field is parsed and ignored at mount time.
Token provider
Implement MiniAppTokenProvider to mint short-lived, scoped tokens for mini-app bridge sessions (see MiniAppScopedToken):
class MyTokenProvider implements MiniAppTokenProvider {
@override
Future<MiniAppScopedToken> mintScopedToken(
String miniAppId,
List<String> scopes,
) async => MiniAppScopedToken(
token: await tokenService.exchangeForMiniApp(miniAppId, scopes),
expiresIn: 300,
scopes: scopes,
miniAppId: miniAppId,
);
}Manifest v2 (signed)
The YAML manifest above is trusted because it ships inside your app. Manifest v2 is for mini-apps whose manifest can come from anywhere (a bundled file, remote config, a server): it is trusted only because a registry key signed it.
A v2 manifest is a JWS (compact form). The header must say "alg": "EdDSA" (Ed25519). The package rejects every other algorithm, including none and HS256.
ManifestVerifier.verify returns a VerifiedManifest or a ManifestRejection. It runs these checks in order and stops at the first failure:
| Check | Rejection reason |
|---|---|
| The JWS, header and payload parse | malformed |
alg is EdDSA and kid is a pinned key | unknownKey |
| The signature matches that key | badSignature |
schema is 2 | invalid |
exp is in the future (5 minutes of clock skew allowed) | expired |
version is not lower than the highest version already seen for this app | rolledBack |
Invariants: ids, https origins, bridge_origins ⊆ allowed_origins ⊆ network_origins, known scopes, no token_strategy, lifetime of 90 days or less, min_host_bridge not newer than the host | invalid |
| The kill list does not name the app, the publisher or the key | killed |
Signing keys
The verifier trusts only the public keys in assets/config/mini_app_registry_keys.json:
{"keys": [
{"kid": "2026-a", "x": "<base64url Ed25519 public key>"},
{"kid": "2026-b", "x": "<base64url Ed25519 public key>"}
]}Ship two keys: the current one and the next one. Then you can move to the next key without a store release. If the file is missing or has a bad entry, no key is pinned and every v2 manifest is rejected. Keep private keys off devices and out of the repository.
Rollback, kill list and scopes
ManifestRollbackStoresaves the highest verified version of each app inMiniAppStorage. An older signed manifest is still rejected after the app restarts.MiniAppKillListis read from the remote config keymini_app_kill_list. It is not signed, because it can only take access away: it removes apps (apps), publishers (publishers), signing keys (kids) or single scopes (scopes). It can never grant anything.MiniAppScopeCataloguelists the scopes a manifest may ask for, and the capability each one unlocks. An unknown scope rejects the manifest.VerifiedManifest.toRuntimeManifest(effectiveScopes)builds the v1MiniAppManifestthat the bridge dispatcher enforces.- The
paymentandconsentcapabilities come only from a verified v2 manifest. A YAML or remote-config v1 manifest cannot grant them.
PartnerManifestRegistry loads the bundled assets/config/mini_apps/*.jws files, then the remote config key mini_app_manifests_v2 (a JSON list of JWS strings). When two manifests have the same app, the higher version wins.
DI and startup
WebViewAdvancedGlobalModule registers PartnerManifestRegistry as a lazy singleton. Resolve it with getIt<PartnerManifestRegistry>(). The brick adds a degradable PartnerManifestInitializer to the host's post-launch initializers, which calls load() for you, so there is no manual step. load() never throws. Call refreshKillList() when remote config updates.
The registry reads these optional host registrations from the root GetIt:
| Registration | Purpose |
|---|---|
MiniAppStorage | Keeps anti-rollback versions. Without it, an in-memory store is used and rollback protection resets on restart. |
RemoteManifestReader, instanceName: kMiniAppManifestsV2ReaderName | Returns the JSON list of JWS strings (mini_app_manifests_v2). |
RemoteManifestReader, instanceName: kMiniAppKillListReaderName | Returns the kill list JSON (mini_app_kill_list). |
Where to put signing keys
The brick writes assets/config/mini_app_registry_keys.json with {"keys": []} and declares it, and the empty assets/config/mini_apps/ folder, in your app pubspec.yaml. With zero keys the registry is fail closed: every v2 manifest is rejected as unknownKey. To trust a publisher, add its public keys to that file (format above), then put signed *.jws files in assets/config/mini_apps/ or serve them from remote config.
The brick also generates devtools/scripts/miniapp_signer/, a small Dart tool for this. Run it from the project root:
# Make a key. The private key goes outside the repo.
dart run miniapp_signer keygen --kid 2026-a --out ~/.miniapp-keys/2026-a.jwk.json
# It prints {"kid":"2026-a","x":"..."}. Add that to "keys". Make and pin the
# next key (2026-b) now too, so you can rotate without a store release.
# Sign a manifest JSON file into a compact JWS.
dart run miniapp_signer sign --key ~/.miniapp-keys/2026-a.jwk.json --kid 2026-a \
rewards.json -o apps/my_app/assets/config/mini_apps/rewards.jws
# Check a JWS against the pinned keys.
dart run miniapp_signer verify \
--keys apps/my_app/assets/config/mini_app_registry_keys.json \
apps/my_app/assets/config/mini_apps/rewards.jwskeygenwill not write the private key inside a git work tree.--forceoverrides this and prints a warning. It never overwrites a key file, and the file gets mode0600. Keep the key in a secret store (a CI secret, a KMS or a password manager), never in the repo.signrefuses a manifest the app would reject at once: noschema: 2, or anexpthat is in the past or more than 90 days afteriat. The app checks the rest (origins, scopes, rollback, kill list).verifyruns the same envelope and signature checks asManifestVerifier, plusschemaandexp.- A golden test keeps the tool and the app in step: a JWS made by the signer is checked in
features/webview_advanced/test/mini_app/manifest_v2/fixtures/and must passManifestVerifier.
Partner consent
A partner mini-app gets a consent-class scope (identity, profile.*, location, camera, payment) only after the user allows it. The grants live in shared/consent_api (ScopedConsentGate, see shared_consent). This brick adds the UI and the rules on top. The brick generates shared_consent first when the project lacks it, and stops when consent_api is older than 0.1.1.
| Type | Role |
|---|---|
ConsentPlanner | Pure functions. forLaunch(manifest, decisions, syncAvailable:) and forRequest(manifest, decisions, scopes, syncAvailable:, declinedThisSession:) return a ConsentPlan (toAsk, missingRequired, blocked, allowsLaunch(answer)). |
PartnerConsentController | commit pushes through ScopedConsentSync first and stores only when the push succeeds. revoke and removeAccess update the device first, then push. |
PartnerConsentSheet | The host-drawn sheet. PartnerConsentSheet.show(...) returns the answer, or null for "Not now" while a required scope is pending. |
PartnerMiniAppsSettingsPage | Lists every partner with a grant. Opens PartnerMiniAppConsentDetailPage: one toggle per scope and "Remove access". |
When the sheet appears:
- First launch: every required scope, plus the optional ones, off by default.
- Later launches: only required scopes that are not granted. A new manifest version that adds a required scope asks for that scope only. A required scope revoked in settings blocks launch and is asked again. A new optional scope is not asked at launch.
- In session:
forRequestasks only declared, optional, not-granted scopes. A scope declined in this session is not asked again. - No consent backend: with
NoopScopedConsentSync,identityis never asked, so a mini-app that requires it stays blocked.
final controller = PartnerConsentController(
gate: getIt<ScopedConsentGate>(),
sync: getIt<ScopedConsentSync>(),
clearPartnerData: (subject) => partnerHost.clearData(subject),
);
final plan = ConsentPlanner.forLaunch(
manifest,
controller.gate.decisionsFor(manifest.subject),
syncAvailable: controller.sync.isAvailable,
);
if (plan.toAsk.isNotEmpty) {
final answer = await PartnerConsentSheet.show(
context,
manifest: manifest,
toAsk: plan.toAsk,
onAllow: (a) => controller.commit(
manifest.subject, a, policyVersion: '${manifest.version}'),
);
if (!plan.allowsLaunch(answer)) return; // blocked
} else if (plan.blocked) {
return; // a required scope cannot be granted on this host
}The sheet shows the publisher logo, name and a "Partner" badge, then the required scopes without toggles, the optional scopes as toggles, and location and camera in their own group with a note that the system will also ask. If the push fails, the sheet stays open with an inline error so the user can retry, and nothing is stored. It cannot be dragged away during a save. Privacy and support links appear when you pass onOpenLink.
In the detail page, turning off a required scope first shows a warning ("Acme Rewards will not open until you allow this again"), then records the refusal and calls clearPartnerData. "Remove access" calls revokeSubject and clearPartnerData. If the push after a revoke fails, the refusal still holds on the device and a snackbar says it is not synced; it is not retried.
The pages take their dependencies in the constructor. The routes (/settings/mini-apps, /settings/mini-apps/:subject) and the clearPartnerData wiring come with the partner host. Strings are in l10n/en.i18n.json and l10n/id.i18n.json and follow the app locale (locale_core).
Web side of the bridge
The package ships archipelago-miniapp.js (ES module) and archipelago-miniapp.umd.js (script tag) in features/webview_advanced/web/. Copy one into your mini-app's web project. It gives the page invoke(capability, method, params), typed errors, host events and a mock mode for development in a normal browser. See the Mini-app bridge guide.
File upload, download, geolocation, and in-page camera/mic
WebViewAdvancedConfig gates four browser-level capabilities, all fail-closed (false/no-op by default):
| Flag | Wires | Default behavior when off |
|---|---|---|
enableFileUpload | onShowFileChooser (delegates to flutter_inappwebview's own default file-chooser UI) | <input type="file"> has no effect |
enableFileDownload | onDownloadStarting, forwarding each request on WebViewInAppWebViewAdvancedController.downloadStream | Downloads are silently ignored |
enableWebRtc | onPermissionRequest — grants CAMERA/MICROPHONE/CAMERA_AND_MICROPHONE | getUserMedia() is denied |
enableGeolocation | onGeolocationPermissionsShowPrompt | navigator.geolocation is denied |
This package has no HTTP, file-system, or permission_handler dependency of its own — matching the bring-your-own pattern TokenExchangeHttpClient uses for token exchange:
- Downloads: listen to
downloadStreamand fetch/save/share the file yourself (e.g. withdio+share_plus). A download is only forwarded while the current page is a trusted origin, andsuggestedFilenameis already sanitized to a bare basename (..,/,\, NUL stripped, falls back to'download') — you must still apply your own size limit (don't blindly trustcontentLength), a MIME allowlist, and never silently overwrite an existing file at the destination path. enableWebRtc/enableGeolocation: also pass aruntimePermissionChecker— aFuture<bool> Function(WebViewRuntimePermission permission)that reports whether the OS-level permission (camera,microphone,location) is already granted. Without one, both flags stay effectively denied even whentrue, since this package cannot request the OS permission itself.
final config = WebViewAdvancedConfig(
initialUrl: 'https://app.example.com',
enableFileUpload: true,
enableFileDownload: true,
enableWebRtc: true,
enableGeolocation: true,
runtimePermissionChecker: (permission) async => switch (permission) {
WebViewRuntimePermission.camera => Permission.camera.isGranted,
WebViewRuntimePermission.microphone => Permission.microphone.isGranted,
WebViewRuntimePermission.location => Permission.location.isGranted,
},
);
final controller = webViewSDK.createController(config)
as WebViewInAppWebViewAdvancedController;
controller.downloadStream.listen((request) async {
// Fetch request.url yourself, then save/share the file.
});Native permission keys
Only add these to your app's AndroidManifest.xml/Info.plist if you actually enable the matching flag above — requesting unused permissions is a common App Store/Play Store review rejection and a real privacy cost, so this brick does not inject them unconditionally into every generated app.
enableWebRtc— Androidandroid.permission.CAMERAandandroid.permission.RECORD_AUDIO; iOSNSCameraUsageDescriptionandNSMicrophoneUsageDescriptioninInfo.plist.enableGeolocation— Androidandroid.permission.ACCESS_FINE_LOCATION(and/orACCESS_COARSE_LOCATION); iOSNSLocationWhenInUseUsageDescriptioninInfo.plist.enableFileUpload/enableFileDownload— no manifest/plist entry is needed for the default file-chooser/download flow on current Android and iOS versions.
Known limits
- Top-level URL checks. Download gating (and the per-call check in
jsHandlers) reads the top-level URL viagetUrl(). The plugin'sDownloadStartRequestdoes not say which frame started a download, so an iframe download on a trusted page passes, and a back/forward-cache navigation can briefly report a trusted URL. The bridge itself is not affected by iframes (main-frame-only). TreatdownloadStreamas untrusted. - No user-activation signal. Neither the bridge envelope nor the plugin reports user activation, so
shareandhapticscannot be gated on a real user gesture.shareis rate-limited (one in flight, 2s cooldown, http/https only) instead;hapticsis not throttled. - Filenames.
suggestedFilenameis sanitized (basename, control/bidi characters and..stripped, 255-byte cap) but the host must not percent-decode it, and must enforce its own size/MIME limits.
Debug tooling: wiring the Bridge inspector
MiniAppContainer/MiniAppContainerPage accept an optional onBridgeEvent callback, forwarded to MiniAppBridgeDispatcher.onEvent. Wire app_debugger_miniapp's BridgeInspectorSDK.observerCallback into it to feed the debug panel's "Bridge" tab — no dependency on app_debugger_miniapp_api is added to webview_advanced itself.
onBridgeEvent must stay debug-only — always gate it behind kDebugMode (or an equivalent debug-only check) at the call site, as in the example below; never wire it unconditionally into a release build. auth capability results are redacted ('<redacted>') before reaching this callback regardless, since auth.getToken's result is a live, usable scoped token — but every other capability's params/result pass through unredacted:
MiniAppContainerPage(
manifest: manifest,
webViewSDK: getIt<WebViewAdvancedSDK>(),
onBridgeEvent: kDebugMode
? getIt<BridgeInspectorSDK>().observerCallback
: null,
);Theme capability
The theme capability lets a mini-app use the same branded theme as the host app, and follow it live. It needs no provider. Grant theme in the manifest. MiniAppContainer reads the ui_kit tokens from its own BuildContext (through context.colorsOrNull, radiusOrNull and spacingOrNull, so the generated class prefix does not matter).
| Call or event | Payload |
|---|---|
theme.get() | { brightness: 'light' | 'dark', tokens: { '--color-bg': '#EEF2FF', '--radius-md': '8px', '--spacing-lg': '1.0rem', ... } } |
theme.changed (host event) | same shape |
tokensholds the semantic colors (background, foreground, action, border, status backgrounds, overlay), the radius scale and the spacing scale, for the current mode. The names are the ones the Tailwind preset uses (monorepo_toolkit ui-kit export-tailwind, see ui_kit_generator). Archipelago checks the names and the light and dark values against the exporter golden in its own template. Generated projects do not get that test, because a customized kit changes the values. Palettes, type, shadows and--color-overlay-lightcome only from the preset.- The container sends
theme.changed300 ms after the app theme settles. A system light/dark switch triggers it when the app follows the system (ThemeMode.system); an app pinned to one mode sends nothing, because its theme did not change (so the Material theme animation sends one event). It sends only while the page is on abridge_originsentry. It also reports each push toonBridgeEvent, so the Bridge inspector lists it astheme.changed. themeis read-only design tokens with no user data. It is inkRemoteSafeCapabilities, so a remote manifest can grant it.- In the page,
await archipelago.followTheme()sets<html data-theme>and the CSS variables, and keeps them in sync. See the bridge guide.
Share and haptics capabilities
share and haptics follow the same host-supplied-provider pattern as storage/permissions/profile: pass null to leave the capability unregistered even if the manifest grants it.
class AppShareProvider implements MiniAppShareProvider {
@override
Future<void> share({String? text, String? url}) =>
SharePlus.instance.share(ShareParams(text: text, uri: url != null ? Uri.parse(url) : null));
}
class AppHapticsProvider implements MiniAppHapticsProvider {
@override
Future<void> impact(MiniAppHapticsImpact impact) => switch (impact) {
MiniAppHapticsImpact.light => HapticFeedback.lightImpact(),
MiniAppHapticsImpact.medium => HapticFeedback.mediumImpact(),
MiniAppHapticsImpact.heavy => HapticFeedback.heavyImpact(),
MiniAppHapticsImpact.selection => HapticFeedback.selectionClick(),
};
}
MiniAppContainerPage(
manifest: manifest,
webViewSDK: getIt<WebViewAdvancedSDK>(),
shareProvider: AppShareProvider(),
hapticsProvider: AppHapticsProvider(),
);Implement the other providers
Three more provider interfaces unlock the rest of the working capabilities this brick ships. All three follow the same shape: pass null to leave the capability unregistered even if the manifest grants it.
class AppStorage implements MiniAppStorage {
@override
Future<String?> get(String namespacedKey) =>
_prefs.getString(namespacedKey);
@override
Future<void> set(String namespacedKey, String value) =>
_prefs.setString(namespacedKey, value);
@override
Future<void> remove(String namespacedKey) => _prefs.remove(namespacedKey);
@override
Future<void> clearNamespace(String namespacePrefix) async {
for (final key in _prefs.getKeys()) {
if (key.startsWith(namespacePrefix)) await _prefs.remove(key);
}
}
}
class AppPermissions implements MiniAppPermissions {
@override
Future<MiniAppPermissionStatus> request(MiniAppPermissionType type) async {
final result = await _toPermission(type).request();
return _toStatus(result);
}
@override
Future<MiniAppPermissionStatus> status(MiniAppPermissionType type) async {
final result = await _toPermission(type).status;
return _toStatus(result);
}
}
class AppProfileProvider implements MiniAppProfileProvider {
@override
Future<Map<String, dynamic>> getClaims() async => {
'id': currentUser.id,
'displayName': currentUser.name,
'avatarUrl': currentUser.avatarUrl,
'locale': currentUser.locale,
};
}
MiniAppContainerPage(
manifest: manifest,
webViewSDK: getIt<WebViewAdvancedSDK>(),
storage: AppStorage(),
miniAppPermissions: AppPermissions(),
profileProvider: AppProfileProvider(),
);StorageHandler namespaces every key as miniapp:<manifestId>:<key> before calling MiniAppStorage — one mini-app cannot read another's data even if they share the same backing store. ProfileHandler intersects getClaims()'s result with MiniAppManifest.profileClaims — only whitelisted keys ever reach the mini-app.
Token Exchange 2.1.0+
Each mini-app can independently opt into one of two auth.getToken strategies via token_strategy in its mini_apps.yaml entry:
mini_apps:
- id: rewards
# ...existing manifest fields...
token_strategy: exchange # default: interceptor (MVP)
exchange_endpoint: /api/mini-apps/token-exchange # optional overridetoken_strategy: interceptor(default) — unchanged MVP behavior:auth.getTokenreturns whatever yourMiniAppAuthProvider.getTokensupplies as-is, andMiniAppAuthInterceptorinjects that same token on bridge-origin requests.token_strategy: exchange—auth.getTokencalls a backend token-exchange endpoint viaExchangeMiniAppTokenProvider(ships with this brick, implementsMiniAppTokenProvider) to mint a short-lived, audience-bound token scoped to that mini-app. Wire your instance intoMiniAppContainer.tokenProvider/MiniAppContainerPage.tokenProvider.
ExchangeMiniAppTokenProvider handles scope narrowing against the manifest's granted capabilities, in-memory caching until expires_in - 30s, single-flight de-duplication, expires_in clamping to 300s, and 401-refresh-retry-once session handling. You supply four seams: a TokenExchangeHttpClient (this package has no HTTP dependency of its own — bring your own client), a host session token supplier, a host session refresh hook, and a manifest lookup function.
The backend contract (request/response shape, error codes) is described in the token-exchange doc comments of the generated ExchangeMiniAppTokenProvider.
Integration
1. Implement Handlers
class GetTokenHandler implements JsHandler<void, String> {
@override
String get handlerName => 'getToken';
@override
void parseArgs(List<dynamic> args) {}
@override
Future<String> handle(void args) async =>
await tokenService.getAccessToken();
@override
Map<String, dynamic> serializeResult(String result) => {'token': result};
}2. Create Config
final config = WebViewAdvancedConfig(
initialUrl: 'https://app.example.com',
trustedOrigins: WebViewTrustPolicy.origins(['https://app.example.com']),
interceptors: [AuthInterceptor(tokenProvider)],
jsHandlers: [GetTokenHandler()],
);3. Create Controller
final controller = getIt<WebViewAdvancedSDK>().createController(config);4. Call from JavaScript
const token = await window.archipelago.getToken();5. Post Native Messages to JS
await controller.postMessageToJs('userLoggedIn', {'userId': '123'});window.archipelago.onNativeMessage = function(name, payload) {
if (name === 'userLoggedIn') console.log('User:', payload.userId);
};Auth Interceptor Example
class AuthInterceptor implements WebViewRequestInterceptor {
const AuthInterceptor(this._tokenProvider);
final TokenProvider _tokenProvider;
@override
Future<WebViewRequest?> intercept(WebViewRequest request) async {
final token = await _tokenProvider.getAccessToken();
return request.copyWith(
headers: {...request.headers, 'Authorization': 'Bearer $token'},
);
}
}Android — R8/ProGuard
The post-gen hook appends flutter_inappwebview's callback keep rules plus a @android.webkit.JavascriptInterface keep (for the JS bridge) to the host app's proguard-rules.pro automatically — no manual setup needed. This only applies when the host app was generated by flutter_modular_monorepo or app_scaffold (both ship the archipelago:proguard:keep-rules marker pair this brick writes into); if the marker pair is missing the contribution is skipped silently.
Provider Capability
| Feature | flutter_inappwebview |
|---|---|
| JS bridge | Full (Promise-based, per-handler channels) |
| Header injection | Main navigation load only (via loadUrl; sub-resources are not intercepted) |
| File upload | Full (onShowFileChooser, requires enableFileUpload) |
| File download | Full (onDownloadStarting -> downloadStream, requires enableFileDownload) |
Changelog
Run archipelago brick info webview_sdk_advanced to see the latest published version. Enterprise customers can read the full CHANGELOG.md in the brick source repository.