Mobile & screens
native mode — a mini-app written in Dart
You write real Dart — no JSON, no DSL. The Okta app downloads the source,
compiles it on the device (once per published version, then caches it) and
renders the widget your main() returns, with the full platform identity.
No compiled artifact ever leaves your repository. On publish, okta-web
reads every .dart file under okta_app/native/<entry>/lib/ into a single
signed source bundle, so what is reviewed is exactly what runs.
Layout (shipped ready in the boilerplate)
okta_app/native/main/
├── pubspec.yaml ← pins okta_miniapp — do not bump the ref
├── analysis_options.yaml
├── lib/
│ └── main.dart ← entry point: Widget main()
└── tool/
└── validate.dart ← the same gate CI runs
The entry is okta_app/native/main/lib/main.dart — a .dart file under
lib/ only, no ... Enforced both at save and at publish.
The smallest thing that works
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
/// Okta calls this to obtain your root widget.
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// The scalars, not `Okta.context()`: that returns a decoded **map** with
// snake_case keys, so `ctx.tenantId` does not exist and `ctx['tenantId']`
// is a missing key — and reading a missing key is the nastiest trap in
// this runtime (below).
return Scaffold(
appBar: AppBar(title: const Text('My app')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tenant: ${Okta.tenantId()} Role: ${Okta.roleId()}'),
ElevatedButton(
onPressed: () => Okta.toast('Hello'),
child: const Text('Say hi'),
),
],
),
);
}
}
The host contract — package:okta_host
Okta.* is the only way out of the sandbox. The current contract is 27.
| Call | What it does | Min contract |
|---|---|---|
Okta.contract() |
The host contract number on this device | 1 |
Okta.locale() · Okta.tenantId() · Okta.roleId() · Okta.isDark() |
Identity and appearance, as plain scalars | 1 |
Okta.context() |
The same values as a decoded map, keyed locale/tenant_id/role_id/is_dark — prefer the scalars |
1 |
Okta.get(path) · Okta.getQuery(path, query) · Okta.post(path, body) · Okta.api(method, path, body, query) |
HTTP through the host — paths are allow-listed to /api/<your-slug>/… and the scope-gated partner API. Tenant auth is attached for you. Returns an OktaApiResponse — see "what they return" below |
1 |
Okta.scanBarcode() · Okta.scanNfc() |
Full-screen scanners returning one read (String?) |
1 |
Okta.uploadFile(path) |
Pick and upload to an allow-listed path — a decoded map, or null if the user cancels |
1 |
Okta.toast(message) |
A host-branded notice | 1 |
Okta.storeGet/storePut/storeDelete/storeKeys |
Durable key-value storage, namespaced to your app. storePut answers false on refusal; it never throws |
5 |
Okta.playSound(name) |
success · error (or failure) · warning · chime — a closed vocabulary, each with a haptic. chime is «a pupil went through and counted», and is not a contract change (see «playSound is unchanged and is not replaced») |
5 |
Okta.location() |
One coarse fix — a map: ['latitude']/['longitude']/['accuracy']/['error'] |
5 |
Okta.preciseLocation() |
Best the device can do, slower — same shape | 6 |
Okta.appIcon(size) |
Your app's icon as a host-drawn widget (the sandbox cannot load an image) | 7 |
Okta.close() |
Leave the mini-app — the sandbox has no Navigator |
9 |
Okta.openDocument(path, fileName) |
Fetch an API path and hand it to something that opens it. false is an ordinary answer |
11 |
Okta.toolKeyStart/toolKeyStop/toolKeyReads/toolKeyChannels |
Tool-key readers (NFC/Bluetooth/wired/LAN gate) — a drain, not a stream | 12 |
Okta.hasCapability(name) · Okta.requestCapability(name) |
Retired — the gate behind them was removed; both always answer true on 19+. Kept so published apps still compile. |
13 |
Okta.remoteImage(path, size) |
A host-fetched, host-drawn image from an API path. A path, never a URL | 15 |
Okta.cameraScanStart/cameraScanStop/cameraScanReads · Okta.cameraPreview(size) |
An embedded scanner inside your own layout — a drain, like the tool keys | 15 |
Okta.studentHashid() · Okta.portal() |
The student your tab was mounted on, and the portal (student/guardian) — '' on any other surface |
17 |
OktaTabs.floatingBar family (in okta_kit) |
Okta's floating bottom bar as a ready widget — no host call behind it | 18 |
Okta.toolKeyFingerprint(key) |
A card's fingerprint as the student roster publishes it — for matching a scan with no network | 20 |
Okta.onMessage(handler) |
Register the instant-message handler — one, and a second call replaces it. No unsubscribe: the host closes the socket on teardown. See "Instant messages" | 22 |
Okta.playAudio(url) · Okta.stopAudio() |
Play an audio file from an absolute http(s) URL your own server serves, and silence whatever is sounding. A second call replaces what is playing, and stopAudio silences the player and the voice. See "Audible announcements" |
23 |
Okta.speak(text) · Okta.canSpeak() |
Speak a string in the device's voice, and probe first. Ask canSpeak before offering "announce by name": many devices ship with no Arabic voice, and false is a sound answer, not a fault |
23 |
Okta.canRecord() |
Can this device record at all? Ask before you draw a record button — a classroom display has no microphone, and a phone's permission can be permanently denied. It does not request the permission, so it is safe to call while drawing | 24 |
Okta.recordStart() · Okta.recordStop() · Okta.recordCancel() |
One take at a time. recordStop returns an opaque handle naming the take (String?); the bytes never cross the bridge. See "Voice notes and file uploads" |
24 |
Okta.playRecording(handle) · Okta.uploadRecording(path, handle) |
Let the person hear the take, then send it. A retired handle is refused, never swapped for another | 24 |
Okta.uploadFileOfKind(path, kind) |
Pick and upload, with kind from a closed vocabulary: image · audio · video · document · any. The old uploadFile is unchanged |
24 |
OktaNfc.canListen/start/reads/stop (in package:okta_nfc) |
The content of an NFC card — its number, the technologies it presents and the text written on it. Drain, not push; Android only; needs a grant | 26 |
Okta.toolKeyStartOn(channels) |
Opens only the readers you name — nfc · keyboard · uhf_bluetooth. toolKeyStart() is unchanged and still opens every one. See “Open only what you need” |
26 |
OktaFace.availability/start/reads/stop · OktaFace.preview(size) (in package:okta_face) |
Who the school's recognition camera saw — the person's type and id, never their image or template. availability answers with a word that says who can fix a refusal. Drain, not push; a Windows machine running the recognition service; needs a grant. See “Who the recognition camera saw” |
27 |
The mini-app is granted no dart_eval permissions: no network, no
filesystem except through these calls.
The card-key form — one form, whichever call returned it
Okta.scanNfc(), Okta.toolKeyReads() and OktaNfc.reads() hand back the
key in the same canonical form okta-web stores: lower-case, no separators (:, -, spaces),
no control characters. A reader that emits AA:BB:CC and one that emits
aabbcc both reach you as aabbcc.
Compare directly. Do not .toUpperCase() or .toLowerCase() first: it
would not hurt — the normalisation is idempotent — but it hides any future
drift instead of surfacing it, and it makes your code look like it knows
something about the form that it does not.
If your app carries a workaround for this, delete it.
Okta.scanNfc()used to return raw UPPER-case while the tool-key path returned the canonical form, so a card enrolled on the tool screen did not match itself when read throughscanNfc. It never looked like a fault: the scan succeeded and the lookup missed, so the app reported an unregistered person. Fixed in the host; the two surfaces agree now.
The same applies to the WebView bridge: the nfc event now carries the
canonical form where it used to carry UPPER-case.
Open only what you need — toolKeyStartOn (contract 26)
Okta.toolKeyStart() opens every reader the device has, which is right for
a gate that takes any card. It is wrong for a screen that reads one kind: the
other radios are powered for nothing, and NFC is a session the OS grants to a
single owner — so an app reading barcodes with NFC left open holds it away
from Okta.scanNfc() and OktaNfc for as long as its screen is up.
// Closed vocabulary: nfc · keyboard · uhf_bluetooth
final ok = await Okta.toolKeyStartOn(['nfc']);
if (!ok) {
// A device without that reader — an ordinary answer; offer something else
}
The same answer toolKeyStart gives: true when at least one opened,
false when none did, and it never throws. Nothing else changed —
toolKeyReads, toolKeyChannels and toolKeyStop work the same whichever
call opened the readers.
Read toolKeyChannels() afterwards; do not trust the list you passed. That
is not belt-and-braces: a name the host does not recognise is dropped, and
a request with no recognised name left is read as "every channel" — so
['NFC'] with a capital opens all three rather than one.
The direction is deliberate. An app that computes its list at runtime and
arrives at nothing should get a working reader rather than a dead one — a
reader that silently opens nothing is the failure this capability exists to
prevent. But it does mean a typo widens instead of narrowing, and nothing
on screen can tell you. toolKeyChannels() can:
await Okta.toolKeyStartOn(['nfc']);
final live = await Okta.toolKeyChannels(); // ['nfc'] if the narrowing worked
And toolKeyStart() is untouched — it gained no parameter and was not
rerouted. If your app opens everything, there is nothing for you to do, and no
reason to raise minContract to 26 for a symbol you never call.
The simulator can lag the phone, and that is structural rather than a fault. The "virtual device" is a build artifact taken from okta-app and published into the portal by hand, so it carries a contract number of its own that may be older than the table above. A symbol above the simulator's contract does not answer
falsethere — it does not compile at all, with a message naming a file you never wrote (Cannot find static method, orCannot find import).Today both simulator shells sit at contract 27, so everything in the table above compiles there. The rule stands for the next contract: when the phone moves ahead before the shells are rebuilt, exercise the new symbol on a real device.
Reading a card's content — package:okta_nfc (contract 26)
Everything above answers whose a card is. This answers what is on it: the same number, the technologies the chip presented, and the text written on it (NDEF records). It never writes to a card and never alters one — and it never will: writing is irreversible on hardware a school hands to a child.
The number is the same number. row['key'] here is byte for byte what
Okta.scanNfc() returns for the same card: one reading on the host, one
normaliser, one canonical form. An app that moves to this surface keeps
resolving every registered tool through the same GET /tool-keys/resolve, with
no re-linking and no second registry. Do not normalise it again, and do not
upper-case it before comparing.
Ask before you draw
import 'package:okta_nfc/okta_nfc.dart';
if (await OktaNfc.canListen()) {
// draw the listen button
} else {
// fall back to Okta.scanNfc() — same number, one card at a time
}
canListen() answers false on anything but Android, on a device with no NFC,
and when the school has not granted your app the capability. All three are
ordinary answers about ordinary devices. A button drawn without asking is a
button that answers a press with nothing — the failure this probe exists to
prevent.
The scope you need
nfc.android_listener.read in your manifest's scopes[]. It is a resource
of its own rather than a wider action on the tool-key scopes, deliberately: a
tool key is an identifier the platform issued and can revoke, while the bytes
beside it on the chip were written by whoever programmed the card and the
school holds no register of them. Two different questions, so the school is
asked twice.
The grant is read on every launch, so withdrawing it from the install screen closes the capability on the next open — no publish required.
minContract: 26 is mandatory
An app that calls this library must declare mobile.minContract: 26.
Omitting it does not make the app work on an older host: a mini-app compiles
on the device, so a missing library is a compile failure. The difference is
that the host recognises a missing import and shows "update the app" instead of
leaving the user in front of a compiler message.
And there is no "try 26, else fall back to 25" in one published version: the
compiler resolves the symbol before any condition runs, so merely having the
call in your code is enough. The choice is made at publish time — declare 26
and use both surfaces (this one and Okta.scanNfc()) as each situation needs,
or declare nothing and stay on the old one everywhere.
Drain in a loop; do not wait
Nothing can call into your mini-app, so reads are collected and drained —
the same shape as Okta.toolKeyReads():
Future<void> _drain() async {
final rows = await OktaNfc.reads();
for (final row in rows) {
final key = OktaJson.str(row, 'key'); // the same number scanNfc gives
final text = OktaJson.str(row, 'text'); // what is written on the chip
_handle(key, text);
}
await Future.delayed(const Duration(milliseconds: 300));
if (_listening) {
_drain();
}
}
Guard the loop with your own flag: State.mounted is not bridged, so an async
gap cannot ask whether it is still on screen. A loop that forgets itself runs
until the app dies.
Row shape: key · at (ISO-8601 UTC, when the host read it) · text
(every text record joined by newlines, or '') · technologies · records
(each with tnf, type, text).
text sits beside records on purpose: the common question is "what is
written on this card?", and nested structures coming out of a JSON decode need
care inside the interpreter.
Limits
- Android only. iOS gives a system sheet per read rather than a background
loop, which is
Okta.scanNfc()'s shape, not this one. The web has no session at all. - One session per device.
OktaNfc.start()takes the radio from the multi-channel tool-key reader, so do not expectOkta.toolKeyReads()to keep producing NFC rows while you are listening — callOkta.toolKeyStart()again afterOktaNfc.stop()if you want it back. stop()throws the buffer away. Buffered reads are cards waved minutes ago, and replaying them into a fresh screen acts on something nobody saw.- The simulator refuses honestly:
canListen()always answersfalsethere, so the fallback is exercisable in the browser, and the real reading on an Android device.
Who the recognition camera saw — package:okta_face (contract 27)
A school runs the face-recognition service on a Windows machine in the building — at the gate, the front desk, the hall door. This chapter lets your app ask it to run while your screen is up, and tells you who it recognised: the person's type and platform id, and when they were seen.
Nothing else reaches you — no image, no template, no match score, no name. The host downloads the school's templates, with the permission of the person signed in on that very machine, and hands them to the engine on the same computer. A template in an app's hands is a template it could send anywhere, and a child cannot be issued a new face — so no symbol in this library can carry one, in either direction.
Ask before you draw — and the answer is a word
import 'package:okta_face/okta_face.dart';
final status = await OktaFace.availability();
if (status == 'ready') {
// draw the start button
} else {
// say why — the word says who can fix it
}
| Word | Meaning | Who fixes it |
|---|---|---|
ready |
A start will work | — |
unsupported |
Not a Windows machine — the engine lives on one. The simulator and display screens always answer this | Nobody — it is a different device |
not_granted |
The school has not granted your app face_recognition.matches.read |
The school, from the install screen |
service_offline |
The service is not answering yet: the host starts it itself when you ask, and a boot can take a minute — or no service is installed on this machine | Nobody while it boots — ask again. If it lasts, a technician: the Okta app's technical settings say why |
no_permission |
The signed-in person may not load the school's faces onto this device. At a complex's gate, also: they no longer hold the two keys there (tenants.children.operate_apps and a permission of your app) |
Whoever grants the school's permissions — or the complex's |
no_faces |
Nobody is enrolled for the model the engine runs | The enrolment desk |
busy |
The camera is held by something else on this machine — usually the enrolment screen | Wait and ask again |
unreachable |
The school's roster could not be fetched just now | The network — retry later |
too_large |
A complex's gate only: the tree is more than one gate may carry — over the platform's ceiling, or longer than the one message the service on this machine accepts. It is never loaded in part | Nobody at the gate: each school runs a gate of its own (which loads that school alone) |
service_outdated |
A complex's gate only: the service on this machine did not prove the Okta app started it, or cannot read the compact roster — an older build, or one started by hand. It still serves a single school, as it always did | A technician: update the service, or close the hand-started one and let the app start its own |
Read any word you do not recognise as "not ready" — the list may grow. Why
a word and not a bool? Because each refusal is fixed by a different person,
and one false would send all four to the same wrong place.
Three words pass on their own, so ask again: service_offline (the service
is booting — the host started it when you asked), busy (the camera is freed)
and unreachable (the network comes back). So do not ask once when the screen
opens and stop there: ask again every half minute while the screen is up and
recognition has not started, as the example below does. The host deliberately
does not wait out the boot for you: availability() answers within seconds
because it is awaited in your own code, perhaps on the same chain that serves
your card readers — and two minutes of waiting there are two minutes in which
no card is read.
The scope you need
face_recognition.matches.read in scopes[] — read-only, and it will never be
anything else. It is read on every launch, like the NFC scope, so
withdrawing it from the install screen closes the capability on the next open
with no publish.
It is not a substitute for education.students.read: this grant tells you
who was seen, by id, and the name comes through the students API if the
school granted you that. One grant does not quietly become two. And
person_id is the same id /api/apps/education/students answers with, so
matching is direct.
minContract: 27 is mandatory
Contract 26's argument, word for word: the library compiles on the device, and an older host recognises the missing import and shows "update the app". There is no "try 27, else fall back" within one published version — the compiler resolves the symbol before any condition runs.
Start, drain in a loop, stop on the way out
bool _listening = false;
bool _closed = false;
Future<void> _open() async {
if (_closed) return;
final status = await OktaFace.availability();
if (_closed) return;
if (status == 'ready') {
if (await OktaFace.start()) {
_listening = true;
_drain();
return;
}
}
// The service may be booting, the camera may be freed, the network may come
// back — so ask again.
if (_passes(status)) {
await Future.delayed(const Duration(seconds: 30));
_open();
}
}
bool _passes(String status) =>
status == 'ready' ||
status == 'service_offline' ||
status == 'busy' ||
status == 'unreachable';
Future<void> _drain() async {
final rows = await OktaFace.reads();
for (final row in rows) {
_arrived(OktaJson.str(row, 'person_type'), OktaJson.str(row, 'person_id'));
}
await Future.delayed(const Duration(milliseconds: 500));
if (_listening) {
_drain();
}
}
void _close() {
_closed = true;
_listening = false;
OktaFace.stop();
}
And _closed twice: before the question, so a delayed one does not ask — and
start the service — for a screen that has gone; and after it, so the camera is
not started for a screen that closed while the answer was on its way. Call
_open() from initState and _close() from dispose.
Row shape: person_type (student or employee) · person_id · at
(ISO-8601 UTC, when the host saw them). All three keys are always present.
Read the row inside a for loop as above — never pass it through ?:
(trap 15).
One row per arrival, not per frame: the engine recognises every frame twice a second, and the host folds that — somebody standing at the camera is one row, and only becomes a second row after being out of sight for a few seconds. So do not write your own de-duplication for that reason. The buffer keeps the newest 256 if you do not drain.
The preview: OktaFace.preview(size) is a host-drawn widget — the live
camera while your run is going, and an empty box of the same size before
start, after stop and while someone else holds the camera, so nothing
around it moves.
One camera, and it has an owner
The machine has one camera and the service runs one thing at a time. So
start() will not take it from anything else — it answers false, and
availability() says busy. And stop() ends your run only: if the
enrolment desk took the camera in the meantime, it is left alone.
The host keeps your run alive for you: if the service is restarted, or the enrolment desk borrows the camera and gives it back, recognition resumes by itself without a call from you — the alternative is a gate that went quiet at 7:40 and says nothing about it. So do not write a "restart if it stopped" loop — once it has started. Before that, asking again is yours (above).
Limits
- Windows only, and only where the service runs. Phones, display screens
and the browser answer
unsupported, which is the branch most devices take — write it. - A school's roster at a school; the operated tree at a complex. When the
platform lets this open operate the complex's schools (the launch tells the
Okta app so — «Your app works inside a complex's schools»), the roster is the
complex's own pupils and those of every school its copy manages whose copy
was granted
face_recognition.matches.read. Pupils only: in this mode no staff member is recognised, not a school's and not the complex's. The row keeps its three keys and names no school — place the pupil yourself, from your card roster'sentity_idor on your server withActForStudentsInTree. And the firstavailability()there waits for the whole tree to download (the faces are forgotten when the screen closes, and fetched again on the next open) — so at a complex, do not hold your card readers back until the face answers. - The simulator answers
unsupported, honestly — a browser tab has no engine. Exercise the whole "not ready" branch there, and real recognition on the school's machine.
The embedded scanner: cameraScanStart arms, cameraPreview opens
Okta.cameraScanStart() arms the camera; it does not open it. The lens
opens when you mount Okta.cameraPreview(size) in your tree, and the preview
is not an optional display — it is the source of the reads. An app that
arms the scanner and never mounts the preview gets nothing from
cameraScanReads(), ever.
The order:
cameraScanStart() → mount cameraPreview(size) → drain cameraScanReads()
And true means "armed", not "open": what is settled before it returns is
the OS camera permission, which is what false reports. Everything after that
is decided by mounting the preview.
This changed in the host.
cameraScanStartused to try to open the lens itself, and it failed on every device: the package refuses to start before the preview widget is built, and the preview was only built after a successful start — a closed loop that made the embedded scanner answerfalsefrom contract 15 onwards. If you tried it once and gave up, try it again with the order above.
A gate that works with no network — the roster and the fingerprint (contract 20)
GET /tool-keys/resolve asks the platform "whose card is this?" — and needs a
network. At a school gate at 7am, the moment you most need an answer is the
moment the network is what failed.
So the roster is downloaded ahead of time:
GET /api/apps/tool-keys/students scope: tool_keys.links.read
{
"students": [
{
"student_id": "01J2X…",
"full_name": "…",
"grade_id": "01J2A…",
"section_id": "01J2B…",
"fingerprints": ["9f2c1d…", "4b70aa…"]
}
]
}
The rows carry fingerprints, not keys, and that is not a restriction — it is what made the roster possible at all. An NFC tool key is the card's UID, so a roster of raw keys is the means to write a card that opens the gate as any pupil on it. A fingerprint matches; it does not manufacture.
So do not compare a scan to the roster directly. Put it through this first:
final reads = await Okta.toolKeyReads();
for (final read in reads) {
final fp = await Okta.toolKeyFingerprint('${read['key']}');
if (fp == '') {
// Could not fingerprint — no keying material cached and no network to
// fetch it. This is NOT "card not registered"; do not treat it as one.
continue;
}
final student = rosterByFingerprint[fp]; // the roster you downloaded
…
}
Three properties you can rely on:
- Stable for one card at one school — the same value every time, and the same whether an NFC or a UHF reader produced the key.
- Different at another school for the same physical card. Never store a fingerprint from one organisation and compare it at another.
- The keying secret never reaches your app. The host holds it and fingerprints on your behalf. If it reached you, every four-byte UID could be generated and the roster turned back into the cards — which is the thing the fingerprint exists to prevent.
The empty string is not "unregistered".
toolKeyFingerprintanswers''when it could not fingerprint — no keying material yet, or a portal session with no organisation. An empty fingerprint compared against the roster matches nobody, so at a gate it looks exactly like a pupil who was never enrolled. Check for it explicitly and tell the operator the device could not verify, rather than that the pupil was refused.
A student with no card appears in the roster with an empty list rather than being left out: "not enrolled yet" and "not at this school" are different answers, and a roster of only card-holders gives both the same silence.
Declare minContract: 20 if you call toolKeyFingerprint. It is a new
symbol in an injected library and your app compiles on the device, so calling
it against an older host dies with Cannot find static method, naming a file
you never wrote. The roster itself is an ordinary HTTP call and needs no floor.
Note for existing apps: this roster sits under the same
tool_keys.links.readscope your app already holds if it usesresolve. No new scope and no re-consent from organisations — but its meaning widened: it was "confirm a card handed to you", and now also covers "download the list of who holds one".
Calling above the device's contract does not fail — it poisons the session
Every row with a bold contract above is backed by an external function the host registers. On an older host it is not registered, and invoking it does not return an error — it corrupts the interpreter for the rest of the session: later calls fail with nothing connecting them to the cause, and you hunt a bug in code that is fine.
Two answers; pick one per call:
- Declare
minContractequal to the highest number you use, so Okta shows "update the app" instead of running you on a host that cannot serve you; or- Guard at the call site:
if (Okta.contract() >= 11) { … }, with a fallback.The scalars,
get/post,toastandscan*need no guard — they have been there since contract 1.
The floating bottom bar — OktaTabs.floatingBar (contract 18)
A rounded strip inset from the screen edges, floating over the content. Just using the widget is enough:
This is no longer the platform's shape. Okta's bar today is solid and pinned to the edge —
OktaTabs.bottomBar; see "The fixed shape every app wears". The floating one keeps working for the apps built on it, so it is a silhouette you may choose, not the default one.
final labels = <String>['Home', 'Register', 'Reports'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.list(), OktaIcons.chart()];
Widget main() {
final palette = OktaPalette.of(Okta.isDark());
return OktaTabs.floatingOver(
content, // your page
OktaTabs.floatingBar(labels, icons, _index, _onSelect, palette),
);
}
floatingOver lays the bar over the body (Scaffold here has no
bottomNavigationBar slot, so the composition is a Stack — and this method IS
that Stack so no app rebuilds it). Give your scrolling child a bottom padding
of OktaTabs.floatingReserve() so its last row can rise clear of the bar.
Customisation is layered — in the same shape as the rest of the class (separate methods, never optional parameters — dart_eval 0.8.5 crashes on an omitted optional of an injected-source static):
| Method | What it controls |
|---|---|
floatingBar(labels, icons, selectedIndex, onSelect, palette) |
identity defaults from the palette |
floatingBarWithDots(… , dots, …) |
+ an unread dot on any tab flagged true (a short list is safe) |
floatingBarTinted(… , background, ring, activePill, activeInk, idleInk) |
every colour in your hands |
Pass colours as Color OBJECTS built from literals at your call site
(const Color(0xFF6D428F)): a Color crosses interpreted parameters intact,
while a raw int passed along and then fed to a bridged constructor arrives
double-boxed and dies with a cast error.
Solid, not glass — deliberately: BackdropFilter is not bridged and BoxShadow is declared nowhere on this runtime, so the bar's depth cue is a one-pixel ring in the border colour — geometry, not a Border (the bridge declares that parameter as BoxBorder and never declares Border as its subtype).
Contract 18. Calling this family on a 17 host dies inside
okta_kitwithCannot find static method, naming a file you never wrote. DeclareminContract: 18, or guard withOkta.contract() >= 18and fall back tobottomBar— which has been there since contract 1, and is the platform's shape today.
flutter analyzewill flagOktaandpackage:okta_hostas undefined — that is expected. The library is injected by the runtime at compile time and is not a real pub package. The authoritative gate isflutter test tool/validate.dart.
What Okta.get / Okta.post return — and how to consume it
Every HTTP call returns an OktaApiResponse: three fields and one derived
getter.
| Field | Type | What it holds |
|---|---|---|
status |
int |
The HTTP code — and 0 when the call never reached a server (network down, or a path the allow-list refused) |
body |
dynamic |
The decoded JSON body — or the raw string when it is not JSON |
error |
dynamic |
A transport/permission failure description; null when none occurred |
ok |
bool (derived) |
error == null and status in 200–299 |
Checking ok is enough for the happy path: it already combines "no transport
failure" with "a success status", so you never need to write
status >= 200 && status < 300 yourself.
final res = await Okta.get('/api/my-app/students');
if (!res.ok) {
// status == 0 means the call never reached a server — do not show "the
// server said…" there, because the server said nothing.
Okta.toast(res.status == 0 ? 'Could not connect' : 'Server said ${res.status}');
return;
}
// body is dynamic — keep it that way. See below.
final dynamic payload = res.body;
final dynamic rows = payload['data'];
if (rows is List) {
for (final dynamic row in rows) {
final dynamic name = row['full_name'];
// …
}
}
The indexing above assumes your endpoint returns a JSON object — it is your endpoint, so that assumption is yours to make. If it answers with something that is not JSON,
bodyarrives as a raw string and indexing it with a String key fails at runtime. You cannot guard withif (body is Map): that guard promotes the receiver and walks you into the same indexing trap.
body is dynamic on purpose, and that is not a detail. If you "tidy it
up" by giving it an explicit Map type:
final Map data = res.body; // ← don't
final rows = data['items']; // Cannot use variable of type String as index
// to map of type <int, Color>
you walk straight into the Map-indexing trap described in the dart_eval list
above, at compile time. The field arrives dynamic from the host for
exactly this reason: leaving it alone is the correct thing to do, not the lazy
one.
error versus status: error is for everything below HTTP — the
network dropped, or the allow-list refused the path so nothing was ever sent.
A server that answers 4xx/5xx arrives with error == null and the code in
status. Both make ok false, but they are different failures to a user: one
is "no connection", the other is "the server refused".
Okta.uploadFile does NOT — it returns a decoded map, not a class. Read it
with ['status'], ['body'], ['file_name'] and ['error'] (note the
underscore in file_name), and it is null when the user cancels the
picker, so check for null before you touch the keys. There is no .ok here
and no .fileName.
Why is this different from
Okta.get? BecauseOktaApiResponseis named byOkta.apiitself, and every app callsapi, so the class always resolves. An injected library may not name a class your app never reaches: dart_eval resolves such a class only when the source pulls it in, so for an app that never uploads a file the name does not exist — and the failure lands on the injected library, naming a file the partner never wrote:CompileError: Unknown type OktaUploadResult. That is whyuploadFile,context,locationandpreciseLocationall return maps.
final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return; // the user cancelled
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
Okta.toast('Uploaded ${up['file_name']}');
}
A complex reads its schools from the mini-app — ?entities=
Your app is open in a complex, and the pupils are in its schools. You need no
server endpoint: the data routes themselves answer for the schools beneath
when you name them, and Okta.getQuery carries the parameter on every host
(no minContract bump). The example below draws a complex's dashboard: its
schools with their logos, and each school's pupil count in one call. It was
run as written on the mini-app engine; the lines that look fussy are there for
the traps in «Traps the devices proved», further down.
// 1) the complex's schools, and whether this person may read them
final tree = await Okta.getQuery('/api/apps/entity/children', {'descendants': '1'});
if (!tree.ok) return;
final dynamic payload = tree.body;
// `== true`, never a bare read: an older platform does not send this key,
// and `== true` is the comparison that reads an absent key safely.
final bool allowed = payload['can_read_children'] == true;
if (!allowed) {
setState(() { _allowed = false; }); // do not draw the download button at all
return;
}
final List<String> ids = [];
String joined = '';
final dynamic nodes = payload['data'];
for (final dynamic node in nodes) {
if (node['type'] == 'school' && ids.length < 20) { // up to 20 per call
final String id = '${node['id']}'; // interpolated, so the interpreter owns it (trap 1b)
ids.add(id);
joined = joined == '' ? id : '$joined,$id';
}
}
// 2) each school's pupil count — one call, one key per school in the answer
final res = await Okta.getQuery('/api/apps/education/students', {
'entities': joined,
'per_page': '1', // we want the total, not the rows
});
if (!res.ok) return;
final dynamic answers = res.body['entities'];
final List<dynamic> rows = [];
for (final String id in ids) {
final dynamic answer = answers[id];
// A school that answered carries no `error` key, and a refused one no
// `total`. A missing key is a raw null: ask `identical`, never `== null` (trap 7).
final dynamic error = answer['error'];
if (identical(error, null)) {
rows.add({'id': id, 'total': '${answer['total']}', 'error': ''});
} else {
rows.add({'id': id, 'total': '', 'error': '$error'}); // one refused school does not drop the rest
}
}
setState(() { _schools = rows; });
In build, one loop over _schools; the logo is a path the host fetches
(404 when there is none, drawn as empty space):
Okta.remoteImage('/api/apps/entity/children/${school['id']}/logo', 32)
One school, with its pupils and their photos — ?entity= returns that
school's answer as is, so you read it with the same code you read your own
entity's pupils with:
final one = await Okta.getQuery('/api/apps/education/students', {
'entity': schoolId,
'per_page': '50',
});
// then, per row:
Okta.remoteImage('/api/apps/education/students/${row['id']}/photo?entity=$schoolId', 40)
Three things shape this:
- No loop inside a loop. dart_eval does not compile one (see «The
supported dart_eval subset» below), so do not walk the schools and then each
school's pupils in one function. Take from the envelope what needs no second loop (
total, orerror), and read one school's rows with?entity=when it is opened. - A refused school is one key, not the answer — and its key has other
fields. An answer carries the route's fields and no
error; a refusal carrieserror(missing_scopewithscope, orentity_not_found) and nototal. Test which one you hold withidentical(answer['error'], null):answer['error'] == nullreads the missing key as false, and storing what it returns kills the mini-app. can_read_childrenbefore the button, read with== true. A person without the permission has the whole request refused (children_not_readable), so do not draw for them what will be refused. A platform older than this feature does not send the field at all, and== truereads that as «no» — the same answer, for the right reason.
The whole rule — which grant a school is read with, and when a request is refused — is in the chapter «Companies and complexes: the tree and its schools' data».
Validate locally — the same gate CI runs
cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart
This compiles your code against the exact device runtime, so anything outside the supported subset fails in CI rather than on a user's phone.
And pub get itself may refuse before anything reaches CI.
pubspec.yaml carries a ceiling on the Dart SDK version (not Flutter's —
see the environment: comment in the file itself; pub does not enforce the
Flutter bound on the root package, measured rather than assumed) because
okta_miniapp hand-writes class $Container implements Container, and
Flutter 3.41 added a member (Container.isAntiAlias) the pub.dev release
does not know about. A newer Flutter is refused by pub get with one line
naming the version to install — not a false "Got dependencies!" followed by
a compile failure later.
The supported dart_eval subset — read this before you write
The device runtime is dart_eval + flutter_eval, which support a subset
of Dart/Flutter. The list below is not a style preference — every item breaks:
-
Host calls through the static
Okta.*facade only — never top-level host functions. -
No
State.mounted— it is not bridged. CallsetStatedirectly. -
Callbacks must be closure literals, not method tear-offs:
onPressed: () => _doThing(), neveronPressed: _doThing("Cannot box Function"). -
Never pass
cond ? null : closureto a callback slot. Pass an unconditional() => …and guard inside the method (if (_busy) return;). -
Index JSON on a
dynamicreceiver — never through aMap-typed one.body['key']compiles and dispatches correctly whilebodyisdynamic. Give that receiver a raw-Mapstatic type — anis Mapguard promotes it, or you annotate a variable, parameter or fieldMap— andoperator[]switches to static resolution. dart_eval 0.8.5 then binds rawMapto an arbitrary bridged instantiation (Map<int, Color>, MaterialColor's swatch shape) and rejects your String key at compile time:Cannot use variable of type String as index to map of type <int, Color>. The<int, Color>never comes from your data — don't go hunting for it. Keep JSONdynamicend to end (final dynamic v = body['key'];) and guard on the result (v is List), never on the receiver you are about to index.is Listpromotion is fine — onlyMapis affected. -
No nested loops. dart_eval 0.8.5 throws a
RangeErrorat compile time when a loop's body — directly, or through a method it calls — contains another loop. Flatten hierarchical data into one flat list first (on the server, ideally), then run ONE loop over it with a loop-free row builder. -
Buttons:
ElevatedButton/TextButtononly —FilledButtonandOutlinedButtonare not bridged. -
Layout:
Row/Column(+Expanded).WrapandAlignmentDirectionalare not bridged — build grids N-per-row withRow+Expanded, and reach forDirectionality(notAlign+AlignmentDirectional) for RTL. -
Always pass
flex:toExpanded/Flexible. The bridge does not apply the parameter's default, so an omittedflexarrives asnulland is cast toint—type 'Null' is not a subtype of type 'int', thrown while the widget builds. WriteExpanded(flex: 1, child: …). The same holds for any bridged widget whose optional parameter is a non-nullableint. -
BoxDecorationcarries onlycolorandboxShadow.borderRadius,gradient,imageandshapesit in the bridge commented out, and an argument the bridge does not declare is silently discarded rather than rejected — it compiles clean and the value simply never reaches Flutter. SoBoxDecoration(borderRadius: …)paints square corners with no error anywhere, andvalidate.dartcannot catch it because nothing fails. Round withClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)). -
BoxDecoration(border: …)does not compile at all. The parameter is declared, typedBoxBorder— but the bridge never declaresBorderas a subtype of it, and the compiler knows only what the bridge declares. An ordinaryBorder.all(…)fails withCannot assign argument of type Border to parameter of type BoxBorder. Draw a hairline as a 1px box:Container(height: 1.0, color: …).Those last two are the same trap from opposite ends: an argument the bridge does not declare is dropped without a word, while one it does declare is type-checked without mercy.
-
No arbitrary
Icons.*constants — use text or labels instead. -
Prefer simple
Map/Listshapes over deep generics across the host bridge.
When your change reaches the device — and when the old copy stays
The Okta app compiles the source on the device once, then caches it. The key is not the contents of the bundle — that is the whole point. It has four parts:
| Part | Moves when |
|---|---|
slug |
a different app |
entry |
a different entry package under the same slug (see "Several entry packages" below) |
payloadVersion |
the module row's updated_at moves — and every install carrying a new commit moves it |
runtimeSignature |
a library the host injects changes, i.e. with a new Okta app release |
On sandbox: push, then reinstall — do not bump the version
You do not need a new version for every change. The loop is:
- push your code to the version's branch;
- reinstall the same version on sandbox.
The install re-resolves the branch's commit every time (the commit_resolve
step), so okta-web receives a different commit, the module row goes dirty,
updated_at moves, payloadVersion changes, and the device's cache misses and
recompiles. install_on_sandbox accepts any existing version, not only
editable ones.
When the old copy really does stay
When none of the four moved. In practice there is one case: you reinstalled without pushing — same commit, nothing dirty, so the source is not re-pulled and nothing recompiles. That is correct rather than broken: there is nothing new to deliver.
This misleads more than it blocks. No error, no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work. So the first thing to check is: did your push actually land on the branch the version points at? Not the code.
The bundle is built compressed at install — and rebuilt when it goes stale
Since compression was enabled the bundle is no longer built per launch. okta-web builds it once at install for each native entrypoint, gzips it and stores it, so a launch serves a file instead of walking your tree again. The Okta app downloads it, decompresses it and runs it.
The file is no longer frozen. It used to carry min_contract and
capabilities as they read at install time and not move until you reinstalled —
and that gave partners the worst debugging day there is: you publish a change,
the version number moves on every device, and the old code keeps running behind
it looking perfectly current. okta-web now compares the file's age, on every
launch, against the tree it can actually deliver: once your new code has
landed on the platform, one rebuild fires and the fresh file is served. And
while a publish's code download is still in flight (or has failed), devices
keep being offered the previous version — its number and its file together —
never a new number wrapping old code.
What the portal shows is what devices receive on their next open — no manual reinstall for a manifest that moved.
Your normal loop is unchanged: push, then reinstall when the files change (pulling your source is still what an install does); a manifest-only edit now arrives on its own.
And on production
Tenants receive what is published. There a new version is the mechanism — not because caching works differently, but because nobody reinstalls on their behalf.
Publishing is two steps under the hood: the platform records your version, then downloads your code with a one-hour token. Devices are only ever told about the tree that actually landed — and a stalled download no longer stays stalled: okta-web retries it automatically with a freshly minted token, from the first device launch that meets the wedge and from a scheduled sweep every ten minutes. So "the portal says 1.4.0 but phones still offer 1.3.0" for a short while after publishing is the system being honest, not your update being lost; if it persists, open the module's status — it carries the exact pull error.
Conversely, an Okta app update moves
runtimeSignatureand recompiles your app on every device without you publishing anything.
This misleads more than it blocks. There is no error and no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work, so you start undoing it and trying other solutions to the wrong problem. In fact your fix never reached the device at all.
If you see old behaviour after a change you trust, check the version number before you check the code.
min_contract — the host-contract floor, per account type
The host contract is versioned. Declare the floor your code depends on and the Okta app shows "update the app" instead of running a mini-app the device is too old for — and since the launch answer started carrying the floor, it says so before downloading the bundle, not after compiling it.
- The floor belongs to the account type, not to the app. Each row of the
Okta-app account-types list in the version editor has its own "minimum host
contract" field under its entry (manifest:
audiences[].minContract). A staff package written against a newer host must not lock the guardian package out of the phones it still compiles on. (And when you want a newer package for newer phones without locking the older ones out at all, that isversions[]— the next section.) - A type that declares none inherits the block default (
mobile.minContract, which the MCP toolset_mobile_surface.min_contractwrites). What the editor exports asmobile.minContractis a mirror of your primary type's floor, so a manifest that only ever set the block value keeps meaning exactly what it meant — nothing published before this field changed behaviour. - Declare the floor the host features you call actually need. (The old "≥ 13 because you declared a device capability" rule is gone with the gate.)
- Applies to
nativeonly; the editor clears it in any other mode. - One
.dartpath is one contract. The same entry declared twice with different floors — an audience and the dashboard card, or a screen place — builds with the first declaration and logs a warning at publish; it is not refused. Give each floor its own package instead. (It is refused when one side is an extra entry line — see the next section.) - And the floor is optional — blank is a real answer: leave it empty and the
entry runs on every device, the oldest included unless the device clears a bound line with a higher contract — the server tries the lines first and falls back to the default. okta-web
resolves a missing floor as
own → block → 1and 1 is below every host that has ever shipped. That is what every app published before per-type floors declares, so nothing makes you pick a number until you need one. Written or resolved, though, it is a floor no extra line of that type may repeat: a default with no floor claims contract 1, and a line bound at 1 is a second answer to one question — see the next section. - Under the field the editor lists which okta-app releases run which contract
(
1.1.1 → 26 · …), mirrored from the table the Okta team keeps on okta-web (partners:sync-okta-app-releases, hourly). Pick the floor your code needs, then read there which phones it reaches. - MCP:
set_account_typestakesmin_contractper row (native rows with an entry only; a value below 1 is refused by name);get_account_typesandget_mobile_surfaceprint each type's floor, the block default and the exported mirror;validate_manifestrefuses a malformedminContractand warns on one path declared with two contracts.
Extra entries for an account type — audiences[].versions[]
A floor says do not run this entry under host X. It never says what to run instead — so a package written against a newer host used to leave you two options: lock the account type away from older phones, or stay on the old package forever. An extra entry line is the third: more than one entry for the same account type, and the server picks.
Every line is bound on exactly ONE axis, your choice:
| Axis | Says | When |
|---|---|---|
minContract |
"a build running host 28 or newer gets this" | the precise question — the contract is what decides whether the package compiles on the device at all |
minAppVersion |
"a build at or above 1.2.0 gets this" | for the builds that cannot answer the precise question: every okta-app shipped before the X-App-Contract header reports a version and no contract |
A line with neither is refused: it names an entry and never says who gets it, so no phone exists that the server could hand it to.
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 20,
"versions": [
{ "minContract": 28,
"entry": "okta_app/native/main_v3/lib/main.dart" },
{ "minAppVersion": "1.2.0",
"entry": "okta_app/native/main_v2/lib/main.dart" }
] }
]
How okta-web picks, in order. The phone's contract comes from its
X-App-Contract header, or is inferred from the release table when it only
reports a version. Then:
- Contract-bound lines (highest floor first): the first floor the phone's contract clears. An unknown contract clears nothing here — the line names a host, and "probably" is not a host.
- Version-bound lines (newest bound first): the first bound the phone's version meets (and, when its contract is known, whose floor it clears).
- The default
entry, whatever its floor — the device gate shows "update the app" exactly as it does today.
So a phone that reports neither a usable contract nor a version it can meet always gets the default. This is the whole compatibility promise: add a line and nothing that already works changes — every okta-app build older than the two headers keeps getting exactly what it gets today.
One host contract names one entry inside an account type — and the default
entry's own minContract is one of them. A line sitting on the default's floor
would win for every phone that reports a contract and lose for every phone that
does not, which is an answer nobody means to give. The editor, the MCP tools
and publish all refuse it by name.
The default entry's floor is optional. Leave it blank and the entry runs on
every device unless the device clears a bound line with a higher contract — the server tries the lines first and falls back to the default. That is how it has always worked, since okta-web resolves an
absent floor as own → block → 1. Write one when your package genuinely needs
a newer host, not because the field demands it. Note that a default without a
floor claims contract 1 on the server, so a line bound at 1 is refused as a
second answer to the same question.
A version-bound line's floor is optional. Leave it out and it inherits the type's floor, which is what okta-web gives it anyway. Write it when the package needs a newer host than the default does; the editor fills it from the release table when it knows the contract that release shipped with.
You may leave entry empty when you declare lines, and it means "this type
is served on those builds only". Say it on purpose: the type then disappears
from every phone that meets none of the bounds, and from every phone that
reports neither its contract nor its version. The editor warns beside the list,
the MCP reply says it in words, and the reviewer sees it as a red badge.
One bound names one entry on the version axis too. 1.02.0 and 1.2.0 are
the same release, and okta-web pins the canonical spelling into the signed
launch URL — so declaring both is refused rather than left as a row no launch
could ever reach.
One .dart path is still one contract, and here it is an error, not the
warning the legacy overlap gets: an extra line naming a path another
declaration builds at a different floor would be a second number that never
reaches a device. Give each floor its own package.
Ten lines at most. The list is an entry history, not a table — drop the bounds nobody runs any more.
The simulator does not simulate this. A browser tab has no app version and no contract to compare, so it lists your extra entries and mounts the default. Try an extra package on a device running that host.
MCP: set_account_types takes versions per row (whole-list replace with the
rest of the type, same as the default entry); get_account_types and
get_mobile_surface print every line by the axis it is bound on and flag a
type with no default; validate_manifest refuses a line with no bound, a
malformed bound, a duplicate bound or contract, and an extra path that collides
with another declaration's contract.
The usual path is to write the lines in manifest.json and call
sync_from_manifest — they cross into the version exactly as the default entry
does. While the platform is still rolling the shape out across both okta-web
deployments, the lines are refused by name on write and the tools say so:
that refusal is about the platform, not your manifest.
pubspec.yaml — do not bump the ref
okta_miniapp:
git:
url: https://github.com/TahdirIT/okta-miniapp.git
ref: __MINIAPP_REF__ ← filled in by the platform when your repo is created
The ref is pinned to the exact runtime the published Okta app was built with, so what compiles for you compiles identically on the device. Bumping it yourself breaks that guarantee.
Manifest shape
"mobile": {
"supported": true,
"mode": "native",
"runtime": "dart",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 12,
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart", "minContract": 12 }
]
}
runtime: "dart" is added automatically in native mode — do not write it
yourself. minContract on the block is the primary type's mirror; the value
that counts is the one on each audience, and a row without one inherits the
block's.
Several entry packages under one slug
<entry> in okta_app/native/<entry>/ is the name of a standalone Dart
package, and nothing ties you to one. Two audiences may point at two different
packages:
"audiences": [
{ "key": "staff", "mode": "native",
"entry": "okta_app/native/staff_app/lib/main.dart", "minContract": 26 },
{ "key": "family", "mode": "native", "portal": "guardian",
"entry": "okta_app/native/family_app/lib/main.dart", "minContract": 20 }
]
Each package is bundled and cached separately. The source bundle carries the
lib/ tree of the requested entry only — sibling packages are never folded in —
and the cache key carries entry beside the slug for exactly this reason.
(Without it the two collide: same slug, same payloadVersion, so whichever
launched first wins the entry and the other replays its bytecode and dies with
Cannot find package:….)
So if your staff screen and your guardian screen are substantially different,
split them into two packages — cleaner than one large if, and each compiles
and caches on its own.
Each package also carries its own floor: above, the staff package needs contract 26 while the family package still runs on 20, so a guardian whose phone is on 20 keeps opening the family screen while the staff screen shows "update the app" — one app, two floors, and no phone locked out by a package it never runs.
When the app will not open at all — the payload failure family
The card appears, you tap it, and loading fails before any of your code runs. This is not a bug in your mini-app: the launch endpoint succeeded (so the install, the scope and the audience are all fine) and the source-bundle download then failed.
The entry shape is enforced three times, not two: on save in the version
editor, on publish in okta-web, and once more when the bundle is served —
and that third one is what you see as a failed load. Every possible cause is on
the server side:
| Cause | What it means |
|---|---|
| The module is not deployed as code on this instance | The database row exists; the code is not on disk |
okta_app/native/<entry>/lib is missing from the deployed module |
The module shipped without its native tree |
The entry file is not under that lib/ |
entry points at a file that does not exist |
entry does not match okta_app/native/<entry>/lib/**.dart |
An old version grandfathered in before the shape gate |
| The bundle > 10 MB, or > 128 files | You are over the assembly limits |
There is no per-file limit. It was 256 KB and was removed: the compile and the transfer only ever see the total, so splitting a file served nothing. What remains is 10 MB for the whole project and 128 files, and the refusal names the file being read when the budget ran out.
The 10 MB ceiling is almost always hit by data, not logic. A Dart project that reaches it nearly always carries generated files: base64 images, huge const maps, generated translations. Move the data out of Dart and behind an endpoint you read with
Okta.get— what arrives over the network is not compiled on the device, while every byte in your bundle is. This is not about transfer: the bundle travels compressed, but the on-device compile works on the full source, and it runs on the isolate that draws the UI.
What to do: report your app's slug and the time you tried to the platform
team. The server logs the exact refusal, and the app shows it as text on screen
in non-production environments — so the answer exists on one side or the other
and does not need guessing. The first thing to check yourself: that the entry
path in your manifest matches a path that really exists in your repo, character
for character.
Device capabilities — REMOVED, being rebuilt
A second permission axis stood here: mobile.capabilities[] in the manifest, a
closed vocabulary of eleven device resources, a publish-time check, and a gate
inside the Okta app that refused any call an app had not declared and the
person had not consented to. The whole thing has been removed — vocabulary,
gate, consent ledger, permissions screen, per-channel reader filter — and is
being redesigned from scratch rather than patched.
What that means for you now:
- Do not declare
mobile.capabilitiesin a new manifest. The key is still ACCEPTED so that no already-published app fails its next publish, but nothing reads it: it is not normalised, not carried to the device, and not shown on the install screen. - Every device call simply works.
Okta.scanNfc(),Okta.scanBarcode(),Okta.toolKeyStart(),Okta.location(),Okta.preciseLocation(),Okta.uploadFile(),Okta.openDocument()and storage need no declaration and raise no platform prompt. - The operating system still asks. Android and iOS show their own runtime dialog the first time your app touches the camera, location or NFC, and the person manages it in device settings. That was never part of this gate.
Okta.hasCapability()/Okta.requestCapability()still compile and now always answertrue. They are kept because they are compile surface — a published mini-app that calls one would fail to compile ON THE DEVICE if the symbol vanished — so existing guards keep working and always take the granted branch. Write no new guards against them; the replacement re-uses these two names.- There is no "
minContract≥ 13" rule any more. DeclareminContractfor host features you actually depend on, nothing else. - A refusal is still not an exception. A device call can still answer
null/false/ a negative accuracy — absent hardware, an OS prompt the person declined, a reader that never paired. Keep checking return values; that discipline never depended on the gate.
The host contract moved to 19 for this. A device still on 18 or below enforces the OLD gate, and an app written for the ungated world is refused there silently —
scanNfcanswering null with nothing in any log — rather than with a compile error you could see.
Your tab in the student profile (mobile.student_profile_tabs) — native mode only
A guardian opens the Okta app, sees their children, taps one — and the screen becomes the student profile. This declaration gives you your own tab inside that screen: you own its whole body, your information and your actions and your layout, not a card in a template you fill in.
"mobile": {
"supported": true,
"mode": "native",
"minContract": 17,
"student_profile_tabs": [
{
"key": "attendance",
"title": { "ar": "الحضور", "en": "Attendance" },
"entry": "okta_app/native/student_tab/lib/main.dart",
"portals": ["guardian"],
"order": 10
}
]
}
This is not the studentProfile block. That one adds panels, stats and
actions to the student-profile page in okta-web, read by staff, and is
discovered from Livewire classes in your repo. This is a native mini-app
mounted on a guardian's phone. Two audiences, two hosts, two runtimes — so
they are not one array. See Extending the student profile
for that one.
And it is not an account type. mobile.audiences[] answers who opens
your app; this answers where a surface mounts. Merging them would make
"guardian" mean two different screens depending on a sibling field.
Fields
| Field | Required | Note |
|---|---|---|
key |
^[a-z][a-z0-9_-]{0,63}$. An identity, not a label: the platform remembers the guardian's last-opened tab by it, so changing it forgets their choice. |
|
title |
{ar, en}; Arabic is required. A bare string is read as the Arabic title. Up to 60 chars. |
|
entry |
okta_app/native/<package>/lib/<file>.dart. May be the same file as your portal card, or its own package. |
|
portals |
["guardian"] by default. "student" is the same screen a student opens on themselves. |
|
order |
Sort among your own tabs. Your position against other apps' tabs is the platform's to decide, not yours. | |
icon |
Up to 64 chars. |
Four conditions, each of which drops the whole tab
nativeonly. The tab is handed the student's identifier. Underwebvieworexternalthat identifier would have to be posted to a partner-hosted page — moving a child's id onto your server every time the screen opens, outside theOkta.*calls okta-web authorizes one by one. A declaration under any other mode is dropped.minContract≥ 17. The mount point did not exist before that contract. An older device does not error and does not complain — it renders the profile with your tab absent, and nothing in any log says why.- The
education.students.readscope. The tab receives only the student's hashid; without this scope it cannot turn that into a student it knows, so it mounts and shows nothing. - Three tabs maximum. The strip is shared: every app the tenant installed puts its tab in the same row, in front of a parent who came to see their child. An app taking eight seats is not extending the profile, it is annexing it — and the guardian pays in horizontal scrolling.
What you receive at mount — and how to read it
Your tab is mounted for one student, and you receive the student's hashid, never a numeric id. Not cosmetic: a sequential integer would let an app walk the school roster by incrementing; a hashid would not.
Two calls, new in contract 17:
// The student the tab was opened on. A function, not a property.
final studentHashid = Okta.studentHashid();
// Who is reading: 'guardian' or 'student'. One screen, two readers.
final portal = Okta.portal();
Both answer '', never null, when the surface is not a student-profile
tab — the ordinary case on every other surface of your app. '' is not an
error; it means "this is not a student-scoped mount". Do not fall back to
"the current user" there: in a guardian's context there is no student in the
session to fall back to, and an app that guesses one shows a parent another
family's child.
Why
''and notnull? Because'$value'on a null produces the four charactersnull— a perfectly well-formed string that walks straight into an API path with nothing to stop it. An empty string is checkable;'null'is not.
Authorization is not yours to do and not yours to bypass. okta-web re-runs it on every call (a linked guardian, the student themselves, or a delegate with an effective delegation), so a tab reaching for another student gets a 403 rather than data. Do not build your own check, and do not stash the hashid to read later in some other context.
A complete, working example
okta_app/native/student_tab/lib/main.dart — an attendance tab listing the
student's absences:
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
Widget main() => const AbsencesTab();
class AbsencesTab extends StatefulWidget {
const AbsencesTab({Key? key}) : super(key: key);
@override
State<AbsencesTab> createState() => _AbsencesTabState();
}
class _AbsencesTabState extends State<AbsencesTab> {
bool loading = true;
String error = '';
List<dynamic> rows = <dynamic>[];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final sid = Okta.studentHashid();
// "Not a student-scoped mount" is a legitimate state, not a failure —
// say something a reader understands instead of calling the API with an
// empty identifier.
if (sid.isEmpty) {
setState(() {
loading = false;
error = 'Open this tab from one of your children’s profiles.';
});
return;
}
// Your own namespace. okta-web has already checked this reader may see
// this student before the call reaches your code.
final res = await Okta.get('/api/my-app/absences?student=' + sid);
setState(() {
loading = false;
if (res.ok) {
rows = res.body['data'];
} else {
error = 'Could not load the record.';
}
});
}
@override
Widget build(BuildContext context) {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (error != '') {
return Center(child: Text(error));
}
if (rows.isEmpty) {
// "No absences" is good news to a parent — say it, don't render an
// empty table.
return const Center(child: Text('No absences this term. 🎉'));
}
// "Your child" to a guardian, "you" to the student: one screen, two
// readers.
final who = Okta.portal() == 'guardian' ? 'Your child' : 'You';
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Text('$who missed ${rows.length} day(s) this term'),
),
for (final row in rows)
ListTile(
title: Text('${row['date']}'),
subtitle: Text('${row['reason']}'),
),
],
);
}
}
Three things worth noting in this example:
main()returns a Widget, like any other mini-app — a tab is not a new kind of program, it is the same surface with a different mount point. Everything you know aboutnativeapplies: the supported dart_eval subset, the limits, and the traps below.- The empty state is not an error. Someone who reached the tab from outside a student profile deserves a sentence explaining that, not a spinner that never stops and not an API call with a blank identifier.
- Never show a parent an empty table. "No absences this term" is the information; it is what they came to find out.
Your scopes, and the platform's data
education.students.read is required because the tab receives an identifier
and nothing else. If you need more than turning that into a student — the
grade, the section, the timetable — request the matching scopes as described in
Scopes. Your OWN data comes from your own namespace
(/api/<slug>/…) with no extra scope, and it is usually most of what the tab
shows.
Traps
- A dropped tab leaves no trace. Any of the four conditions above drops the
row at build time: the manifest publishes, the app installs, and the tab is
simply not there on the parent's phone. That is why the version editor and
validate_manifestrefuse the row with an error, not a warning — a refusal where you read it beats an absence you never see. - Empty is worse than absent. A tab with an appealing title that shows nothing until data is set up is better left undeclared. Declare it when it has something to say.
- The title has exactly one audience: the guardian. Do not put your app's name or an internal term in it — your app's name is already shown next to the tab.
Traps the devices proved — read these before you ship
Every item here took down a real app on a real phone, and none of them is caught
by flutter analyze or tool/validate.dart. Compilation succeeds; the
interpreter then dies in the user's hand.
1. Okta.* is a BRIDGE CALL, not a field read — cache it once.
Every call round-trips to the host and comes back as JSON to be decoded again.
Every time. One attendance app wrote:
bool get _ar => Okta.locale().startsWith('ar'); // a getter — 188 calls per build
String _t(String ar, String en) => _ar ? ar : en;
Read the identity once in initState and keep it in fields:
late final bool _ar;
@override
void initState() {
super.initState();
_ar = OktaText.isRtl(Okta.locale());
}
It cannot change without the host rebuilding the mini-app anyway. The rule is
wider than identity: treat every Okta.* as network, not as a property.
1b. A value that ARRIVED from outside is not an interpreter value — and the
message names the argument, not the receiver. Calling a bridged method
(startsWith, toLowerCase, trim, contains, substring, even
toString) on a value that came from outside the interpreter dies with:
type 'String' is not a subtype of type '$Value?' in type cast
#1 $String._startsWith
args[0] is perfectly fine — the receiver is the wrong shape, so do not
chase the argument the trace names. Okta.* was fixed from the inside and no
longer hands out values in that shape, but a JSON reader you write does:
String _text(dynamic map, String key) {
return map[key] as String; // the cast passes; the shape stays wrong
}
String _text(dynamic map, String key) {
final dynamic value = map[key];
return '$value'; // interpolation makes the interpreter allocate it
}
Fix it at the mint, not at the call sites, and do the same in the int /
double branches (value.toString() carries the identical defect and merely
fires less often). For bools use == true rather than as bool.
2. Never nest ternaries — in an argument or an assignment alike.
final way = a ? (x ? '…' : '…') : (b ? '…' : '…'); // arrives null
The compiler allocates the outer conditional's result slot and the inner one writes elsewhere:
*L11: null, L12: null
8484: BoxString (L12) <<< EXCEPTION
Branch and return instead. A single ternary is fine. And the type in the
message (String, int) is just whatever the expression produced — do not read
it as narrowing the rule.
3. An uncalled private method fails your own gate.
unused_element is a warning, and flutter analyze is part of
partner-module-policy. Delete a button, delete its handler with it.
4. A device call can still refuse — check what it returns.
The platform's capability gate is gone, so nothing is withheld for lack of a
declaration. What remains is the real world: hardware that is absent, an OS
permission dialog the person declined, a reader that never paired. Every call
answers in its own documented refusal shape — scanNfc → null,
storePut / openDocument / toolKeyStart → false, location → a negative
accuracy — and none of them throws. Ignore the return value and your screen
shows a button that silently does nothing.
5. A stale ref makes your gate validate something the device does not have.
The okta_miniapp pin in okta_app/native/<entry>/pubspec.yaml does not
decide what the device compiles against — the host injects
okta_host/okta_kit/okta_motion from its own binary. It decides what
flutter test tool/validate.dart checks. So a pin behind the host means a gate
validating a source that does not exist, and the failure surfaces on a user's
phone:
CompileError: Cannot find static method OktaTabs.bottomBar
A symbol the host knows and your pin does not, or the reverse. The platform manages this value; if you hit an on-device compile error while your gate is green, the ref-versus-host match is the first thing to check.
6. Silent failure is worse than failure. A local capture is confirmed before the network is consulted — which is correct, it is what makes a scan survive a dropped connection — but it means an upload that never landed looks identical on screen to one that did. Say so: a sound, a message, a log row. Someone standing at a gate is watching the student, not a badge in the header.
7. is is not a safe test on a bridged value — and a missing key is its
worst case. The most expensive trap in this runtime. dart_eval's Map bridge returns map[key]
directly, and the null that a missing key yields is a raw Dart null, not
the interpreter's $null. So value is String does not return false — it
throws:
type 'Null' is not a subtype of type '$Value' in type cast
There is no defensive way to look at one: is throws, and == null is
worse — it answers false, so the value sails on to the next call and dies
there instead.
The rule is wider than a missing key: on a bridged value, a MATCHING is
answers and a non-matching one THROWS. The key is present, the value is
sound, and it still throws — caught on-device by the okta-hr team on the
accuracy that Okta.location() returns:
L23: $"accuracy" L24: 16.950000762939453
5436: Unbox (L24)
5437: NumLtEq (L31 <= L24) ← `is double` matched, and the compare ran
5441: IsType (L24 is 12) <<< EXCEPTION
type 'double' is not a subtype of type '$Value' in type cast
is double succeeded; four instructions later is int threw on the same
value. So any chain of is tests against one value is a time bomb: the
first test that does not match throws instead of answering false. The pattern
is extremely common in JSON reading (is int, then is double, then
is String) and works perfectly until a value arrives whose type is not the
first one in your chain.
The one test that answers absence is identical(value, null) — true for a
missing key and for an explicit JSON null, false for anything present, measured
on the engine. Ask it first, and only then read the value:
final dynamic value = map['accuracy'];
if (identical(value, null)) return; // absent, or null — the one safe test
final accuracy = double.tryParse('$value') ?? 0.0; // present: interpolate, then parse
Once the key is there, the safe read is interpolation — '$value' — the
same fix as trap 1b above, there for boxing and here for inspection: the
resulting string is interpreter-allocated, so every call on it is safe, and it
answers the type without a single is. It does not survive absence:
interpolating a missing key calls toString on the raw null and throws the
same type 'Null' is not a subtype of type '$Instance'. An earlier version of
this guide showed '${map['accuracy']}' answering "null" for a missing key —
it does not; if you copied that line for a key that can be absent, put the
identical test in front of it.
Use is only once the interpolated string has told you it will match.
And not a containsKey guard either. It was tried on-device and
reverted: its boxing does not match operator[]'s, which is special-cased,
so every guarded read fell through to its fallback — it answers "absent" for
keys that are present. Do not write it.
The defence is upstream: make your endpoint always emit every field your
mini-app reads, with null when it has no value. A key that is PRESENT holding
a JSON null is completely safe — jsonDecode wraps recursively, so it arrives
as $null and behaves — and only ABSENCE is fatal. It is your endpoint, so the
guarantee is yours to make.
OktaJson protects you from the shape, not from absence: at excludes
a body that is not an object (null, list, string, number, bool) so an unexpected
reply cannot crash you, and strOr/number/list tolerate the wrong type. A
missing key passes through them exactly as it passes through a direct read. The
exception is OktaJson.flag, which uses == true rather than is and is
therefore absence-safe.
8. setState(() => x = y) boxes twice — use a block body.
An arrow closure returns its body's value, and an assignment is an
expression, so the value is boxed once to write the field and again to return
it — and the second box lands on something already boxed:
type '$bool' is not a subtype of type 'bool' in type cast
7287: SetObjectProperty (L3._exporting = L1)
7288: BoxBool (L1) <<< EXCEPTION
setState(() { _busy = true; }); // produces no value, so no second box
Convert every one, not only the ones that crash: identical-looking sites in the same file shipped for weeks, and whether the double box lands turns on register allocation rather than on anything visible in the source.
9. Reading a bool out of a list is the same trap by another road.
Indexing hands back an already-boxed value, and the compiler — seeing a static
type of bool — emits BoxBool on top of it. Compare, don't read:
final on = i < flags.length && flags[i] == true; // not `flags[i]`
The comparison produces a fresh bool instead of re-boxing the stored one. Strings and widgets index out of a list fine; bools (and numbers feeding a bridged parameter) are what bite.
10. Never initialise a loop counter from a parameter.
An int crossing an interpreted parameter arrives boxed. Used directly it is
harmless — the compiler unboxes it — but int i = from is a local copy:
CopyValue moves the box, the compiler types the local from its int
declaration and believes it unboxed, and the next comparison dies:
type '$int' is not a subtype of type 'num' in type cast
Use literal bounds and repeat the loop, one method per range. Do not widen
this into "no arithmetic on an int parameter" — that rule is false, and
index * 40 ships fine.
11. A primitive crossing an interpreted parameter gets double-boxed.
A literal handed straight to a bridged constructor is fine. The same value
passed through an interpreted function parameter first arrives boxed twice and
dies with type '$int' is not a subtype of type 'int'. That is how an
IconData _icon(int c) helper broke a five-tab bar, once per tab. Build bridged
values from literals at the construction site, or return them from a
zero-argument method the way OktaIcons does. Widgets and Strings pass through
parameters fine.
12. Declared, but not wired: it compiles, then throws.
A class can be known to the compiler and never registered with the runtime, so
it passes validate.dart and then throws while building:
UnimplementedError: Tried to invoke a nonexistent external function
In flutter_eval 0.8.2 that is true of InkWell, InkResponse and
SafeArea. Use GestureDetector for the first two and your own padding for
the third.
The same trap exists on methods: list.sort() with no comparator compiles
and then throws, because the runtime implementation reads args[0] without a
?. Always pass the comparator, or don't sort.
13. Long text wraps; it does not truncate.
Text bridges neither maxLines nor overflow, and TextOverflow is not a
bridged type at all — so there is no ellipsis, and a label wider than its box
wraps, splitting a word across lines. Keep it on one line with
FittedBox(fit: BoxFit.scaleDown, child: Text(…)) inside a bounded width, and
omit alignment (the wrapper defaults it to Alignment.center).
14. No Timer — but there IS a clock, and Future.delayed is awaited, never
handed a callback.
DateTime (.now(), .parse, add, difference, …), Duration and
Future.delayed are all bridged and wired, and so are
jsonEncode/jsonDecode. Only Timer is absent — which rules out a PERIODIC
callback, not a delay. So a drain loop (toolKeyReads, cameraScanReads) calls
itself again after await Future.delayed(duration), and it needs your
own flag to stop it: State.mounted is not bridged, so an async gap cannot
ask whether it is still on screen.
A callback you pass to Future.delayed compiles and is never called: the
bridge takes the duration alone and drops the callback without a word. A loop
that writes Future.delayed(duration, () { _drain(); }) drains once and then
goes quiet, with no error anywhere. Write it like this, inside an async
method:
await Future.delayed(const Duration(seconds: 1));
_drain();
Measured on the runtime that runs on the device, and check_miniapp_source
refuses the first form by name (future_delayed_callback). This guide's own
drain loops used it until they were corrected — if you copied one from an
older copy of the guide, replace it.
15. A JSON element does not survive ?:.
The value of a ?: expression whose branch is an element of a decoded list or
map comes out unboxed, and the next OktaJson call (or any is) dies on
it:
type '_Map<$Value, $Value>' is not a subtype of type '$Value' in type cast
final first = rows.isEmpty ? null : rows[0]; // dies in OktaJson.str(first, …)
final first = rows.isEmpty ? {} : rows[0]; // the other branch changes nothing
dynamic first; // assign inside an if
if (rows.isNotEmpty) { first = rows[0]; }
final name = rows.isEmpty ? '' : OktaJson.str(rows[0], 'name'); // read inside the branch
Measured on the engine: rows[0] directly, rows.first, rows[0] ?? … and a
for loop are all safe — ?: alone unboxes. It was found by a test on the
same engine the device runs, not by a school's complaint: it had been failing
the contract-26 test in the host's own repository since the day it was
written. Avoid it before it becomes one.