DEV Community

XNeuronal
XNeuronal

Posted on

Making a killed app speak: high-priority FCM, headless JS, and the service that buys a voice its right to talk

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));
  }
Enter fullscreen mode Exit fullscreen mode

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)
  ]);
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
superfunicular profile image
Super Funicular

The framing in your closing paragraph is the right one, and I think it has one more consequence you can measure: the high-priority FCM grant is itself metered, and the meter is tied to the exact user behaviour this feature is designed around.

Since Android 9, high-priority FCM messages to a dozing device are budgeted per App Standby Bucket. An app the user opens daily sits in ACTIVE or WORKING_SET and effectively has no ceiling. An app whose whole value proposition is "you never have to open me, the phone just talks to you in the morning" trains the user into never opening it, so it drifts to FREQUENT, then RARE, where the allowance gets small enough to matter. Past the budget the message still arrives, but demoted to normal priority, which means Doze batches it to the next maintenance window and you are back to the briefing landing whenever Android feels like it. Quiet failure, weeks of delay before it shows up, and invisible to any test run on a phone where you just installed the app.

Both of the things you listed as untested are reachable from adb, though. adb shell dumpsys deviceidle force-idle puts the device in real Doze so you can fire a push at it and watch the headless context spin up, and adb shell am set-standby-bucket <pkg> rare (with am get-standby-bucket to confirm it took) drops you into the bucket a disengaged user actually lands in. Running the morning briefing under rare plus force-idle is about as close as you get to the third-week-in-the-field case without waiting three weeks. UsageStatsManager.getAppStandbyBucket() reports it at runtime too, which seems worth surfacing in a diagnostics screen given how much of the design rests on that one grant.

One more on the floor you fall back to: the OS notification is the floor for the voice, but on Android 13+ POST_NOTIFICATIONS is a runtime permission, so the floor is itself deniable. A user who declines that prompt loses the tray line and the voice in the same stroke, and the triple delivery collapses to the inbox row, which only surfaces next time they open the app, i.e. precisely the case the feature exists to cover. areNotificationsEnabled() is cheap, and a denial might be worth treating as a state you degrade into deliberately rather than one you learn about from a support ticket.

The manifest-line-that-breaks-nothing-at-build-time detail is very familiar. We run a long-lived foreground camera service and it has the same shape: a missing foregroundServiceType is a clean build and a runtime throw on 14. And the OEM battery managers are the part no amount of correct manifest buys you out of.

Collapse
 
raknaos profile image
Baptiste Le Bouquin

"Background rights are a currency handed out at specific moments" is the most accurate model of Android I've read in a while. The enqueue-time claim is a genuinely non-obvious call — instinct says start the service when you actually need it, but by then the FCM-granted window has closed. Spending the right the moment it exists is the only correct move, and it costs you a foreground notification that can sit there for minutes before a word is spoken.

The startService() returning null without throwing for an undeclared component deserves its own warning label. I've hit cousins of that failure class in browser-automation tooling — a call that fails by returning null, no exception, and the only evidence is a downstream no-op. How did you finally pin that one down: worker logs, or did you end up wrapping component starts in a PackageManager existence check?

Also worth flagging for anyone on notifee: since the library ships its own foreground service as shortService, the tools:replace line is load-bearing even if you never touch the manifest yourself. One question on the hold — did you bound it with a timeout, or do you let the service run until the poll drains and trust the process lifetime not to accumulate?