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: 0 or 4xx/5xx and an error ← 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() answers false. 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 uploadFile has 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.

View the whole guide on one page · ↧ Raw Markdown