In XNeuronal, the server composes a short spoken briefing in the morning ("two things today, and the rain arrives at four") and pushes it to the phone. While the app is open, that is easy: a WebSocket frame arrives, the client fetches the audio, the assistant talks. The feature only earns its keep in the opposite situation, a phone lying on a table, screen off, app swiped away. Making that phone speak turned out to involve four Android mechanisms, two field-reported bugs, and one manifest line whose absence breaks nothing at build time and everything at runtime.
React Native 0.81, a Node backend, one hand-written Kotlin module. Every block below is copied from the repository at the commit named above it, lightly trimmed.
One message, three deliveries
The backend does not trust any single channel. A proactive message is written to an inbox table (the durable copy), pushed over the live WebSocket (the app-open copy), and sent through FCM (the app-closed copy).
backend/src/services/proactivity/ProactivityCron.ts at 7eb56ec:
/** Triple delivery : inbox row + live WS + FCM (authenticated only). */
private async deliver(owner, kind, content, title, metadata): Promise<void> {
await inboxService
.push({ user_id: owner.user_id, device_id: owner.device_id, kind, content, metadata })
.catch((err) => console.warn('[proactivity-cron] inbox push failed:', err));
notifyOwner(owner, { type: 'inbox_event', kind, content });
if (!owner.user_id) return; // anonymous → no FCM token
await fcmService
.sendToUser(owner.user_id, {
title,
body: pushBody(content),
data: {
kind,
proactive: '1',
body_full: content.length > 1500 ? `${content.slice(0, 1500)}…` : content
}
})
.catch((err) => console.warn('[proactivity-cron] fcm failed:', err));
}
The FCM message is the interesting one, because it is a hybrid. The classic tutorial choice is "notification message OR data message". This is both, on purpose. The notification block means Android displays the tray line by itself, with zero app code, even if everything below fails; the OS notification is the floor, the voice is the bonus. The data block carries proactive: '1', the marker every later gate keys on, and body_full, because the body the OS shows is truncated for a one-line tray entry and you do not want the voice to read an ellipsis.
The send itself sets android: { priority: 'high' } (in FCMService.sendToTokens, same repo). Without high priority, a device in Doze batches the delivery for the next maintenance window and your "morning" briefing arrives whenever Android feels like it; with it, the OS wakes the device and runs the app's background handler on arrival. That handler is the whole trick.
The headless entry point
react-native-firebase lets you register a handler that runs when a push arrives with the app backgrounded or killed. On Android, "killed" means the OS spins up a fresh headless JavaScript context just for this function.
frontend/index.js at 7a0a6ba:
messaging().setBackgroundMessageHandler(async (message) => {
await Promise.all([
LocalReminderService.dedupeAgainstFcm(message?.data),
handleBackgroundReadAloud(message)
]);
});
Two jobs, both best-effort, neither allowed to reject. The first clears a redundant tray copy when a reminder already rang locally. The second is the voice.
Five gates before a word comes out
frontend/src/services/BackgroundReadAloud.ts at 70b1ad4:
export async function handleBackgroundReadAloud(message): Promise<void> {
try {
const text = voiceableText(message);
if (!text) return;
const prefs = await getPrefs();
if (!prefs.proactivity_enabled || !prefs.read_aloud || !prefs.read_aloud_background) return;
if (inLocalQuietHours(prefs)) return;
if (wasRecentlySpoken(text)) return;
markSpokenAloud(text);
await speakProactive(text);
} catch (err) {
console.warn('[background-read-aloud] failed:', err instanceof Error ? err.message : err);
}
}
Each line is a lesson paid for.
voiceableText only accepts pushes stamped proactive: '1'. Reminder pushes are deliberately silent here, and the file's header comment records why: a field report of a reminder that rang as an alarm AND was then read aloud, because the backend sends a safety-net FCM copy of every reminder the phone also schedules locally. A message the user asked for already has its own delivery; voicing the backup copy is a second alert, not a service.
read_aloud_background is a dedicated toggle ("even when the app is closed"), default OFF. Speaking out of a pocket is the kind of surprise that gets an app uninstalled; it has to be asked for.
inLocalQuietHours re-checks quiet hours on the device clock even though the backend already withholds proactive sends during the configured window. The server knows one timezone; the phone may be sitting in another.
And the wasRecentlySpoken pair guards against a subtle double delivery: when the app is backgrounded but still alive, the same message arrives through the WebSocket (the app-open path) and through FCM, in the same JavaScript context, seconds apart. Whoever speaks first stamps the text into a TTL map (three minutes, keyed on the normalized full text) and the other path finds it there.
The voice is a server round-trip
There is no on-device TTS in this path. The assistant has a chosen voice, and the briefing must sound like the assistant, so the headless task calls the same endpoint the live conversation uses.
frontend/src/services/InboxService.ts at dace638:
export async function speakText(text: string): Promise<void> {
const clean = text.trim();
if (!clean) return;
const headers = { ...(await authHeaders()), 'Content-Type': 'application/json' };
const voice = await getVoice().catch(() => undefined);
const ttsRes = await fetch(`${env.backendHttpUrl}/tts/speak`, {
method: 'POST',
headers,
body: JSON.stringify({ text: clean, voice })
});
if (!ttsRes.ok) throw new Error(...);
const ttsBody = (await ttsRes.json()) as { audioBase64?: string };
if (!ttsBody.audioBase64) throw new Error(...);
await ttsService.play(ttsBody.audioBase64);
}
Base64 audio over authenticated HTTPS, played locally. Server side, that endpoint fronts a primary/fallback synthesis pair, so the voice heard app-closed is byte-for-byte the conversation voice. The cost is honest: no network, no readout. The tray notification still landed, which is why the hybrid message shape matters.
The phone that was busy
The first shipped version spoke the moment the push arrived. The push arrives at a fixed hour; the phone alone knows whether that hour is a good moment. A briefing that talks over a phone call is worse than none.
So every readout now goes through a gate, frontend/src/services/SpeechGate.ts at 7a0a6ba:
export async function speakProactive(text: string, hooks?: SpeechHooks): Promise<void> {
const clean = text.trim();
if (!clean) return;
const now = Date.now();
dropExpired(now);
if (queue.some((entry) => entry.text === clean)) return;
if (hooks) hooksByText.set(clean, hooks);
const entry: Pending = { text: clean, expiresAt: now + WAIT_WINDOW_MS };
if (!(await lineTaken())) {
await attempt(entry);
return;
}
await enqueue(entry);
}
lineTaken asks a small Kotlin module two questions off AudioManager, no permission needed: is a call (or VoIP conversation) in progress, is other audio playing. The rule in that module is written at the top of the file: when in doubt, speak. A missing module, a throwing bridge, an unknown platform all answer "free", because an app gone mute from a defensive check is a broken feature, while occasional over-talking is merely the old behaviour.
A busy phone puts the text in a disk-backed queue with a two-hour expiry (a morning recap read at noon makes no sense), then a 5-second poll waits for the line to free up, plus a 30-second courtesy delay so the voice does not start the instant a call ends. The function resolves as soon as the decision is made, never at the end of the wait: a headless task cannot be kept alive for the length of a phone call.
The preference and quiet-hours checks run again at speaking time inside attempt(), not just at queueing time, because a message may have waited an hour and the world has moved on; a digest held back by a call must not wake up inside the silent window.
And the queue is emptied through an exclusive() mutex, added after a field report worth quoting from the comment: reopening the app fired two drain passes 21 milliseconds apart (screen mount, then the app turning "active"), and the same recap was read two or three times on top of itself.
The right to speak at all
This is the part that will bite anyone building background audio in 2026. Since Android 15, the OS refuses audio focus to an app that is neither visible nor running a foreground service. Android 17 goes further: the playback itself fails, silently, in that state. Google leaves one door open, a foreground service that is not of type SHORT_SERVICE.
So the readout runs inside a mediaPlayback foreground service, and the service is claimed at a precise moment: when the message is queued, not when it is finally spoken. The comment in enqueue() explains the economics:
// Claim the foreground service NOW : the push that brought this message is
// what grants the right to start one, and that right expires. Twenty minutes
// later, when the call ends and the poll picks the message up, nothing could
// hand it back.
await holdSpeechForegroundService();
A high-priority FCM push is an official exemption that allows starting a foreground service from the background, and it does not last. Background rights on Android are a currency handed out at specific moments; you spend them when they exist, not when you need them.
Two manifest lines carry the rest. frontend/android/app/src/main/AndroidManifest.xml at 7a0a6ba:
<service
android:name="app.notifee.core.ForegroundService"
android:foregroundServiceType="mediaPlayback"
tools:replace="android:foregroundServiceType" />
<service
android:name=".SpeechRetryService"
android:exported="false" />
The first overrides notifee's own declaration: the library ships its foreground service declared as shortService, the one type the new rule excludes. Removing that line does not break a build; it makes the voice go silent on the next Android version.
The second exists because of the best bug of the chantier. A held-back message must survive the app being swiped away, so a WorkManager job re-wakes a headless task later to drain the disk queue. The service that task runs in was missing from the manifest until one release, and startService() on an undeclared component returns null WITHOUT throwing. The worker logged its wake-up, reported success, and had started nothing. Silence, with green logs.
What the code does not prove
The anonymous mode gets none of this: deliver() returns before the FCM send when there is no user_id, so only signed-in users hear briefings app-closed.
The dedup map lives in memory, per JavaScript context. It covers the real double-delivery case (WS and FCM landing in the same living context); a context that dies between the two would forget, and the disk queue's own by-text check is what catches that. No test pins the boundary between the two guards.
Nothing tests the actual Doze wake, the Android 17 playback refusal, or an OEM task killer. Xiaomi-class "battery savers" can suppress headless JS entirely, and the code's answer to that is the tray notification, not the voice. The Android 17 claim is taken from documented platform behaviour, not from a device running it.
And body_full is truncated at 1500 characters with an ellipsis to stay under FCM's 4 KB data budget, which means a runaway briefing would be read aloud mid-sentence. The cap has never been hit; the guard is there for the day the composer misbehaves.
The lesson I kept: on modern Android, "can my app do X in the background" is the wrong question. The right one is "which event currently grants me the right to do X, and how long does the grant last". A high-priority push grants a foreground service start; a foreground service grants audio focus; audio focus grants a clean cut-off if a call starts mid-sentence. Chain the grants at the moment each exists, write down which failure each guard was bought by, and let the OS notification be the floor that survives when any link breaks.