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".
falseis an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answersfalseagain 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 notOkta.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
minContractfor 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 clicksuccessplays — 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, andstopAudio()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.