The mic stops hearing when the screen goes off, and says nothing #82

Closed
opened 2026-08-01 13:08:20 +00:00 by aiko · 1 comment
Owner

Capture on Android works only while the app is visible. Let the screen go off mid-hold and AudioRecord keeps returning full buffers of zeros — a well-formed recording of nothing, indistinguishable from a quiet room. MicRecorder's audit already logs a verdict on the opening seconds precisely because this failure looks like success.

Established by #81, which resolved the question of what the platform actually demands. Read that first — it carries the sources and the traps.

What to build

A foreground service whose only job is to hold the microphone's while-in-use state open.

  • AndroidManifest.xml gains FOREGROUND_SERVICE_MICROPHONE and a second <service> with android:foregroundServiceType="microphone". FOREGROUND_SERVICE, RECORD_AUDIO and POST_NOTIFICATIONS are already declared.
  • Do not merge the type onto .overlay.OverlayService as specialUse|microphone. The overlay is ruled out by #47; that would resurrect a component whose point is gone and tie the mic's lifetime to code paths that stop the service on permission revocation.
  • Start it before AudioRecord opens and stop it on release. MicRecorder.startCapture / stopCapture is the seam; mic_start in src-tauri/src/audio/android.rs already routes both through with_recorder and already treats a refusal as a visible error rather than a log — which is what a SecurityException from startForeground needs.
  • The ContextCompat.checkSelfPermission guard in startCapture stays, but it cannot be read as "the mic will work now": that call returns PERMISSION_GRANTED for a while-in-use permission even when the app is backgrounded. #81 records the citation.
  • MicRecorder.audit's warning text wants revisiting: once a service exists, zeros mean the service failed to start or lacks while-in-use, not that the screen is off. Two causes, one symptom.

What this deliberately does not do

Push-to-talk while the app is backgrounded. #81 established that a global hardware key is on neither exemption list, so a backgrounded start is not reachable as designed — the platform-sanctioned shape is the foreground service's own notification carrying the action. That is a separate decision about whether v1 wants a mic you can reach without looking at the app, and it is not this ticket.

Acceptance

  • Holding the key with the screen on still captures, exactly as today
  • A hold that outlasts the screen timeout keeps capturing real audio, not zeros — checked against the peak, not against a byte count
  • The service stops on release; it does not outlive the hold
  • A denied RECORD_AUDIO, or a startForeground that throws, surfaces as a visible refusal rather than a silent dead mic
  • Verified on the Pixel 7 Pro with adb shell cmd appops get works.aiko.ollvt.debug RECORD_AUDIO showing no fresh rejectTime after a screen-off hold

The last one is yours — the build is agent work, the verdict is a device.

denpa#81 (the research), denpa#44 (silero and capture on Android), denpa#61 (hear a real word), denpa#23 (the overlay service not to reuse), denpa#47 (why the overlay is out)

Capture on Android works only while the app is visible. Let the screen go off mid-hold and `AudioRecord` keeps returning full buffers of **zeros** — a well-formed recording of nothing, indistinguishable from a quiet room. `MicRecorder`'s `audit` already logs a verdict on the opening seconds precisely because this failure looks like success. Established by [#81](https://git.aiko.works/aiko/denpa/issues/81), which resolved the question of what the platform actually demands. Read that first — it carries the sources and the traps. ## What to build A foreground service whose only job is to hold the microphone's while-in-use state open. - `AndroidManifest.xml` gains `FOREGROUND_SERVICE_MICROPHONE` and a second `<service>` with `android:foregroundServiceType="microphone"`. `FOREGROUND_SERVICE`, `RECORD_AUDIO` and `POST_NOTIFICATIONS` are already declared. - **Do not** merge the type onto `.overlay.OverlayService` as `specialUse|microphone`. The overlay is ruled out by [#47](https://git.aiko.works/aiko/denpa/issues/47); that would resurrect a component whose point is gone and tie the mic's lifetime to code paths that stop the service on permission revocation. - Start it **before** `AudioRecord` opens and stop it on release. `MicRecorder.startCapture` / `stopCapture` is the seam; `mic_start` in `src-tauri/src/audio/android.rs` already routes both through `with_recorder` and already treats a refusal as a visible error rather than a log — which is what a `SecurityException` from `startForeground` needs. - The `ContextCompat.checkSelfPermission` guard in `startCapture` stays, but it cannot be read as "the mic will work now": that call returns `PERMISSION_GRANTED` for a while-in-use permission even when the app is backgrounded. `#81` records the citation. - `MicRecorder.audit`'s warning text wants revisiting: once a service exists, zeros mean the service failed to start or lacks while-in-use, not that the screen is off. Two causes, one symptom. ## What this deliberately does not do Push-to-talk while the app is backgrounded. `#81` established that a global hardware key is on neither exemption list, so a backgrounded start is not reachable as designed — the platform-sanctioned shape is the foreground service's own notification carrying the action. That is a separate decision about whether v1 wants a mic you can reach without looking at the app, and it is not this ticket. ## Acceptance - [ ] Holding the key with the screen on still captures, exactly as today - [ ] A hold that outlasts the screen timeout keeps capturing real audio, not zeros — checked against the peak, not against a byte count - [ ] The service stops on release; it does not outlive the hold - [ ] A denied `RECORD_AUDIO`, or a `startForeground` that throws, surfaces as a visible refusal rather than a silent dead mic - [ ] Verified on the Pixel 7 Pro with `adb shell cmd appops get works.aiko.ollvt.debug RECORD_AUDIO` showing no fresh `rejectTime` after a screen-off hold The last one is yours — the build is agent work, the verdict is a device. ## Related `denpa#81` (the research), `denpa#44` (silero and capture on Android), `denpa#61` (hear a real word), `denpa#23` (the overlay service not to reuse), `denpa#47` (why the overlay is out)
Author
Owner

This was generated by AI during triage.

Merged locally as e2c069a (feature 802ff29, alongside #83 — see below for why they merged as one). Nothing pushed.

MicService.kt is a microphone foreground service and nothing else. hold(context) starts it and waits on a latch for onStartCommand's verdict, returning the refusal string or null; release(context) is a plain stopService, which needs no action to route and is not subject to background-start restrictions. MicRecorder holds before the open(rate) loop and releases on the all-rates-failed path and at the end of stopCapture.

Acceptance

  • The service is not merged onto OverlayServiceandroid-mic-service.test.ts asserts the overlay stays specialUse alone, and that the manifest's declared type agrees with the startForeground constant. That pair throws MissingForegroundServiceTypeException only on a device, which is why it is pinned here.
  • Started before AudioRecord opens, stopped after it closes
  • A refused startForeground becomes MicRecorder.lastFailure, which Rust already turns into a visible error rather than a log
  • The backgrounded global-hotkey path deliberately not built — #81 established it is not reachable as designed
  • Screen-on hold still captures as today
  • A hold outlasting the screen timeout captures real audio — peak, not byte count
  • The service really stops on release
  • adb shell cmd appops get works.aiko.ollvt.debug RECORD_AUDIO shows no fresh rejectTime after a screen-off hold

The last four are the point of the ticket and none of them is done. This was built blind, deliberately and with the operator's agreement. Nobody has watched it.

What review caught

The typed startForeground was gated on API 29. FOREGROUND_SERVICE_TYPE_MICROPHONE is since=30 while the three-argument startForeground is since=29, so at minSdk 24 that would have been a NoSuchFieldError on the first hold. Now gated on 30. Also made hold synchronized, though mic_start's lock already serialises it.

Assumptions that could not be tested

  • That a running microphone service keeps the RECORD_AUDIO app-op granted through screen-off. #81 lists this as unverified and the whole change rests on it.
  • That a denied POST_NOTIFICATIONS hides the notification rather than blocking the start. If it blocks, every capture now fails visibly where it used to fail silently. Louder is better than zeros, but it would be a surprise.
  • That MicRecorder.startCapture does not run on the Android main looper — hold() skips its wait if it does, and a refusal would then be reported as success.
  • That 2000 ms is enough for a cold service creation on a busy main thread. Guessed.
  • That stopService from a backgrounded app reliably tears down an already-foreground service.
  • That the service survives Doze on a long screen-off hold.

Gates

npm test 941 across 73 files, npm run typecheck exits 0, cargo test 28. :app:compileArm64DebugKotlin built, so the manifest merges; no APK was built.

> *This was generated by AI during triage.* Merged locally as `e2c069a` (feature `802ff29`, alongside `#83` — see below for why they merged as one). Nothing pushed. `MicService.kt` is a `microphone` foreground service and nothing else. `hold(context)` starts it and waits on a latch for `onStartCommand`'s verdict, returning the refusal string or null; `release(context)` is a plain `stopService`, which needs no action to route and is not subject to background-start restrictions. `MicRecorder` holds before the `open(rate)` loop and releases on the all-rates-failed path and at the end of `stopCapture`. ## Acceptance - [x] The service is not merged onto `OverlayService` — `android-mic-service.test.ts` asserts the overlay stays `specialUse` alone, and that the manifest's declared type agrees with the `startForeground` constant. That pair throws `MissingForegroundServiceTypeException` only on a device, which is why it is pinned here. - [x] Started before `AudioRecord` opens, stopped after it closes - [x] A refused `startForeground` becomes `MicRecorder.lastFailure`, which Rust already turns into a visible error rather than a log - [x] The backgrounded global-hotkey path deliberately not built — `#81` established it is not reachable as designed - [ ] **Screen-on hold still captures as today** - [ ] **A hold outlasting the screen timeout captures real audio** — peak, not byte count - [ ] **The service really stops on release** - [ ] **`adb shell cmd appops get works.aiko.ollvt.debug RECORD_AUDIO` shows no fresh `rejectTime` after a screen-off hold** **The last four are the point of the ticket and none of them is done.** This was built blind, deliberately and with the operator's agreement. Nobody has watched it. ## What review caught The typed `startForeground` was gated on API 29. `FOREGROUND_SERVICE_TYPE_MICROPHONE` is `since=30` while the three-argument `startForeground` is `since=29`, so at `minSdk 24` that would have been a `NoSuchFieldError` on the first hold. Now gated on 30. Also made `hold` synchronized, though `mic_start`'s lock already serialises it. ## Assumptions that could not be tested - **That a running `microphone` service keeps the `RECORD_AUDIO` app-op granted through screen-off.** `#81` lists this as unverified and the whole change rests on it. - **That a denied `POST_NOTIFICATIONS` hides the notification rather than blocking the start.** If it blocks, every capture now fails *visibly* where it used to fail silently. Louder is better than zeros, but it would be a surprise. - That `MicRecorder.startCapture` does not run on the Android main looper — `hold()` skips its wait if it does, and a refusal would then be reported as success. - That 2000 ms is enough for a cold service creation on a busy main thread. Guessed. - That `stopService` from a backgrounded app reliably tears down an already-foreground service. - That the service survives Doze on a long screen-off hold. ## Gates `npm test` 941 across 73 files, `npm run typecheck` exits 0, `cargo test` 28. `:app:compileArm64DebugKotlin` built, so the manifest merges; **no APK was built**.
aiko closed this issue 2026-08-01 15:12:00 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#82
No description provided.