Mobile & screens
Voice notes and file uploads
Everything your app could send to your server was typed or photographed.
Okta.uploadFile(path) has existed since contract 1, but the host's picker
accepts five extensions (pdf, jpg, jpeg, png, heic), and nothing on this
surface recorded audio at all. A teacher who wants to record a sentence for a
parent, a guardian who wants to answer a dismissal call by voice, a supervisor
who wants to attach the incident report the school already has as a .docx —
none of the three had a way.
Contract 24 opens both: a microphone, and a picker you can tell what you want.
| Call | What it does |
|---|---|
Okta.canRecord() |
Can this device record at all? Ask before you draw a record button |
Okta.recordStart() |
Start recording. false = the host refused (no mic, permission denied, or a take already running) |
Okta.recordStop() |
End the take and get an opaque handle (String?) naming it. null = there was nothing to end |
Okta.recordCancel() |
Throw the take away and release the microphone. void, always safe |
Okta.playRecording(handle) |
Let the person hear what they recorded before it goes |
Okta.uploadRecording(path, handle) |
Upload the take to an API path — the same map uploadFile returns |
Okta.uploadFileOfKind(path, kind) |
Pick and upload, with kind deciding what the picker offers |
All of them are contract 24, so declare "minContract": 24 if you call any
of them. If you call none of them, do not raise it: bumping without need
turns away devices your app was working on.
Record → listen → send
Three verbs, and the middle one is why there are six symbols and not three: a voice note nobody can hear before it leaves is a voice note nobody sends twice.
String? _handle;
bool _busy = false;
Future<void> start() async {
if (!await Okta.canRecord()) {
Okta.toast('No microphone is available on this device');
return;
}
if (!await Okta.recordStart()) {
Okta.toast('Could not start recording');
return;
}
setState(() { _busy = true; });
}
Future<void> stop() async {
final String? handle = await Okta.recordStop();
setState(() { _busy = false; _handle = handle; });
}
Future<void> play() async {
final String? h = _handle;
if (h == null) return;
await Okta.playRecording(h); // the same output playAudio uses
}
Future<void> send() async {
final String? h = _handle;
if (h == null) return;
final dynamic result = await Okta.uploadRecording('/api/apps/notes', h);
if (result == null) return; // cancelled — does not happen here
final int status = result['status'];
if (status >= 200 && status < 300) {
setState(() { _handle = null; }); // sent, so it cannot be sent twice
Okta.toast('Note sent');
} else {
Okta.toast('Could not send: ${result['error']}');
}
}
canRecord() before the button — this is the point
A classroom display has a speaker and no microphone. And on a phone the microphone permission can be permanently denied in Settings, where nothing your app does will ever raise a prompt again.
Without the probe you draw a record button, the user presses it, and nothing
happens: no prompt, no error, nothing in any log. That is exactly the failure
this probe exists to prevent — the canSpeak() lesson, word for word.
And false is an ordinary answer about a working device, not a fault to
report or retry: offer typing, or the file the user already has, instead.
It does not request the permission, so it is safe to call while building a
screen. recordStart() is the call that may raise the prompt.
A handle names one take, and is never reused
recordStop() returns an opaque string naming a take the host is
holding — not the take itself: the sandbox has no filesystem, and the bytes
never cross the bridge. There is nothing in it to read or parse; hand it to
playRecording and uploadRecording and nothing else.
It is retired the moment another take starts, or your app closes. A retired handle is refused by name rather than standing for whatever the host is holding now — and that is the difference worth the extra argument: a parent hears their own voice played back and a different family's recording is what reaches the school.
So keep the handle for as long as its screen, and clear it after a successful send.
One take at a time
recordStart() on a host that is already recording answers false rather
than silently discarding what is being said into the phone. And
playRecording goes through the same audio output playAudio uses
(contract 23): starting one silences the other, and Okta.stopAudio() cuts
whichever is sounding — so you never have to know which it was.
uploadFileOfKind — and the vocabulary is closed
Okta.uploadFile(path) has not changed and will not: its five extensions
stay. Widening it would have changed, silently, what every already-published app
is handed by its picker, with nothing in its code saying so.
So if you want more, say which more:
kind |
What the picker offers |
|---|---|
image |
The device's images |
audio |
Audio files |
video |
Clips |
document |
pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, rtf, odt, ods |
any |
Everything |
The list is closed, because the host draws the picker: a word it does
not know could only degrade quietly into something else on every device in
every school. An unknown kind comes back as
{'status': 0, 'error': 'unknown file kind "…"'} — and the simulator refuses it
too, so your typo is caught in a browser rather than in a school.
final dynamic picked = await Okta.uploadFileOfKind('/api/apps/files', 'document');
if (picked == null) return; // the user cancelled
if (picked['status'] >= 400 || picked['status'] == 0) {
Okta.toast('Upload failed: ${picked['error']}');
return;
}
document is a list of extensions rather than "everything" on purpose: a picker
that offers everything is how someone attaches a 400MB video to a leave request
and then waits for it on school wifi. Ask for any if you mean it.
null means "the user cancelled" — a failure is something else
This is the distinction to write into your code:
null← the user closed the picker. Nothing to do, nothing to show.- a map with
status: 0or4xx/5xxand anerror← a failure. Show it.
And uploadRecording never returns null for a retired handle or a host
with no microphone: those are error results, because nobody cancelled anything —
and an app that reads one as a cancellation drops a voice note believing it sent
it.
Nothing throws
No microphone, a denied permission, a closed picker, a retired handle, a network
that died mid-upload — all false, null, or an error result. Do not write
try around any of the seven; whoever reads "this can fail" writes one, and
the exception does not happen here.
Where it does not work, said honestly
- Web (the browser build and the simulator):
canRecord()answersfalse. The recording package on the web hands back a blob rather than a file, so a take could not travel the same upload leg. - The simulator: refuses all seven, and its file picker answers a
cancellation, exactly as
uploadFilehas since the beginning. A simulator that always says yes never once exercises the fallback path you are being asked to write, and the first place you would find it missing is a screen in front of a real person. - Display screens (okta-screen): refuse all seven — a television on a wall has no microphone and nobody standing at it to pick a file.
So try the recorded path on a real device, and write the fallback that works
when canRecord() answers false.
No manifest key, and no scope
Exactly as contract 23: these are host symbols. There is nothing to add to
manifest.json and no scope to request from the school — the microphone is the
microphone of the device your app was opened on, and the file goes to an API
path your usual scopes already govern. The only thing that moves is
minContract.