Mobile & screens

Audible announcements (audio & speech)

A student-call app announces a child's name when their guardian reaches the gate, and its settings offer the school four sounds: a gentle bell, the name spoken by a synthetic voice, a recording the school made, and a recording the guardian made. In a browser all four worked. On the two surfaces that matter — the display on the wall and the employee's phone — only the bell did, because the host's entire audio surface was Okta.playSound(name): a closed vocabulary of three system tones. Three of the four modes had no path at all.

Contract 23 opens that path with four calls.

The four, and two capabilities rather than one

Call What it does What false means
Future<bool> Okta.playAudio(String url) Play an audio file from a URL your own server serves Playback did not start: an invalid URL, a file that cannot be reached, or a speaker that will not respond
void Okta.stopAudio() Silence whatever is sounding — the file and the voice alike — (returns nothing, and cannot fail)
Future<bool> Okta.speak(String text) Speak a string in the device's voice Speech did not start: no engine, or no voice for the language asked for
Future<bool> Okta.canSpeak() Can this device speak? It cannot — and that is a sound answer, not a fault

Two capabilities, not one, because the modes are different things. The two recordings — the school's and the guardian's — need a file played (playAudio); only the synthetic voice needs speech (speak). So an app that offers recordings alone does not need a TTS engine to exist on the device at all, must not ask about one, and must not disable itself over its absence.

Ask canSpeak() before you offer "announce by name" — this is the point

Many Android TV boxes ship with no Arabic voice whatsoever. Without the probe your app picks the spoken mode, the wall goes silent, and nothing anywhere says why: no error on screen, no line in a log, and no visible difference between "this device has no voice" and "the call never arrived".

false is an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answers false again until somebody installs a voice on that device. The correct response is to step down: the recording, then the bell.

Ask it in two places: when you draw your app's settings, so you never offer a school an option that cannot work on its screen; and at the moment of the announcement, so you never assume that what was true at configuration time is still true.

And the answer is not cached — not by the host, and not by you. A school that installs an Arabic voice on Tuesday uses it on Tuesday; a wall display is not restarted for months, so an answer cached at startup means an installed voice goes unused until the next holiday.

On Windows the host resolves the voice by listing the engine's languages (the per-language probe is Android-only) and returns the engine's own spelling of the tag. You do none of this: the part that concerns you is that canSpeak answers truthfully on both platforms.

Nothing throws — every answer is a bool

A dead speaker, a 404 on the recording, a missing engine: all false. None of the four raises an exception up to you.

That is deliberate, because the audience is a wall: on a display with nobody standing at it, an exception is a red screen nobody clears until the school day ends. So check the returned value, and do not wrap the call in a try.

playAudio takes a URL your own server serves

The file is fetched by the platform's audio player, never by the authenticated client: no bearer and no tenant header rides to a partner file host. The URL therefore reaches only what the device could have fetched anonymously, and the call hands back a bool, not bytes.

In practice:

  • Absolute http(s) only; file:/// and its friends are refused.
  • The file must be served without Okta authentication. A short-lived signed URL your own server issues is a sound answer; an endpoint expecting an Okta token is not — it will answer 401 and you will get false.
  • If the recording lives behind your app's own domain, expose it on a public path your server serves. Do not pass a relative /api/… path: this call is not Okta.get.

One voice in the hall

A second playAudio replaces whatever is sounding, and stopAudio() silences both the player and the voice. There are never two channels open at once.

There is one loudspeaker on a wall, and two names overlapping in a lobby is worse than one name arriving late. So if two calls land back to back, the later one is what is heard — and that is the behaviour you want by default.

true means "started", not "finished"

There is no completion signal on this surface. The Future resolves when the sound starts, so you cannot chain two calls today — "ring the bell, then speak the name" is not expressible, because await on playAudio does not wait for the clip to end.

A known limit, stated now rather than discovered as a queue of announcements trampling each other on a school wall. If you need a sequence, space it with a delay you choose, and know that it is an estimate.

And why false can arrive late

playAudio may answer false late when the server is unreachable: the player waits out its own preparation timeout.

This is a choice, not an oversight. A fast "no" followed by a delayed noise would make the wall play the fallback bell and the recording both — so answering quickly here would buy exactly what the section above prevents.

playSound is unchanged and is not replaced

The bell is a confirmation drawn from a closed, host-owned vocabulary (success · error · warning · chime, each with a haptic); this surface is an announcement. Two different things, which is why the first was not removed.

And the bell is what you fall back to when the device has no voice and the recording cannot be reached — present on every contract since 5. But success is the system click, and the system click makes no sound on Windows — which is to say on the gate computer itself. Call chime there.

chime — «a pupil went through and counted». A short two-note tone the Okta app ships itself, added because a valid pass on the gate computer was silent. success was not changed: that would have changed the sound of every published app at once, with nothing in any of them asking for it — an app that wants the tone says so.

  • Not a contract change. A word in the host's vocabulary, not a symbol in an injected library, so do not raise minContract for it and check nothing before calling it. A host older than the word treats it as an unknown name, which in the Okta app is the very click success plays — the right fallback, not a failure.
  • Never quieter than success. When the tone cannot play — no audio output, or an iPhone (one audio session per app there, so the click stays) — the click plays instead. On Android it respects silent mode, and it ducks whatever is playing under it rather than cutting it off.
  • Its player is its own: it does not cut an announcement from playAudio/speak, and stopAudio() does not silence it. A second call before it finishes starts it again from the top.
  • Use it for what it means and nothing else: «seen at the gate». A pass that counted — an arrival or a departure, and a late one too, because the pass counted; the text says the verdict, not the sound. A pupil who already counted earlier today and comes back to the camera or taps their card gets the same tone with a card that says «already» (what «حضور» does since 1.6.44, at schools' request: silence at the gate reads as «the gate stopped working») — not recorded again, and the colour or the text says which. Saves and settings stay on success: a tone heard on every save stops meaning anything at the gate. And a mechanical repeat (the same card still in the reader's range, a UHF reader reading whatever is in range several times a second) should not sound at all — keep it quiet in your code.

minContract: 23 — and when not to raise it

An app declaring minContract: 23 is refused on an older host with an "update the app" screen — which is the correct refusal, because the alternative is a compile death on a television in a school (Cannot find static method Okta.speak).

Do not raise the floor to 23 unless you actually call one of the four. An app that makes no sound does not move, and raising the floor without needing to does not make your app newer — it makes it refuse devices it used to run on.

An app that wants recordings only still needs 23: playAudio is new too, and anything new in an injected library is a floor, whatever it is for.

The simulator answers false to all four — deliberately

On the virtual device all four always answer false, even though a browser tab could play <audio> and use Web Speech.

The reason is that a simulator which always says canSpeak() == true never exercises the fallback path this chapter is asking you to write — and the first place you would find it missing is a hall full of parents. So use the simulator to test that your app steps down with no error message and no blank screen, then test the spoken path on a real device or a paired screen.

Example: the gate call

Probe, then speak; otherwise the school's recording; otherwise the bell. Note that every step falls silently to the one below it: no toast, no error message — because nothing that happened is an error.

// mode: 'voice' | 'recording' | 'bell' — a setting the school chooses.
Future<void> announce(String name, String mode, String recordingUrl) async {
  // Cut the previous call: one speaker, and two overlapping names are
  // worse than one late name.
  Okta.stopAudio();

  if (mode == 'voice') {
    final bool can = await Okta.canSpeak();
    if (can) {
      final bool spoke = await Okta.speak('Guardian arrived for $name');
      if (spoke) return;
    }
    // No voice on this device — step down, and report no fault, because
    // none occurred.
  }

  if (recordingUrl.isNotEmpty) {
    final bool played = await Okta.playAudio(recordingUrl);
    if (played) return;
  }

  // The bell: the floor nothing falls through — but it is the system click,
  // which makes no sound on Windows. In the Okta app on Windows, `chime` does.
  Okta.playSound('success');
}

And in your settings, do not offer what cannot work:

class _SettingsState extends State<Settings> {
  bool _voiceAvailable = false;

  @override
  void initState() {
    super.initState();
    _probe();
  }

  Future<void> _probe() async {
    final bool can = await Okta.canSpeak();
    setState(() { _voiceAvailable = can; });
  }

  // ... and in build: show "announce by name" only while _voiceAvailable,
  // and in its place a line saying this device has no voice installed.
}

Wired to an instant message — the screen listens, then announces, and the name comes from the payload:

Okta.onMessage((String event, Map data) {
  if (event != 'guardian.arrived') return;
  final dynamic v = data['student_name'];
  setState(() { _name = '$v'; });
  announce('$v', _mode, _recordingUrl);
});

And the previous chapter's rule still holds: never wait for a message in order to draw. An audible announcement sits on top of state you read with Okta.get; it is not a substitute for it.

No manifest key, and no scope

These are host symbols — not manifest keys and not grants. There is nothing to add to manifest.json and no scope to request from the school in order to speak or play a file: the sound leaves the speaker of the device your app was opened on, and reads nobody's data.

The only thing that moves is minContract — so do not go hunting for a key to add.

View the whole guide on one page · ↧ Raw Markdown