Skip to content

Commit 073180f

Browse files
committed
android: a refused connect is not proof the microphone is fine
micOk was set from `e !is MicUnavailableException` on every failed streaming attempt, so ANY non-mic failure wrote micOk=true. The order is what makes that wrong: the socket connects BEFORE the mic opens, so while the household is paused — listener closed — every attempt fails on the connect, and a phone whose microphone was genuinely dead kept reporting a healthy one. That is not a hypothesis about the OnePlus's nine silent hours on 2026-09-06; it is the mechanism. The app retried every two seconds, could not open AudioRecord, and #887's check — built precisely to catch a dead mic — called it healthy throughout, which is worse than having no check. ⚠ #1468 recorded this as "micOk is evidently derived from permission state rather than from whether AudioRecord actually opened". That was wrong and the truth is narrower: it IS derived from the open. A non-mic failure was clearing it afterwards. The rule is now `if (failure is MicUnavailableException) false else previous` — a failure that never reached the microphone reports nothing about it, in either direction — and `true` is only written where the mic actually opened, which the success path already did. It moved out of StreamService into MicState with MicUnavailableException, because a rule nothing can reach is a rule nothing can test, and there was no test for this transition at all. Four now, and two of them fail against the old rule: a mic failure must survive a hundred later refused connects, and a network failure must not invent a mic failure either. Checked by restoring the old expression and watching exactly those two go red. ⚠ THIS DOES NOT FIX THE PHONES. It fixes the app; the phones run what is installed on them.
1 parent 628ad2d commit 073180f

3 files changed

Lines changed: 73 additions & 6 deletions

File tree

android/app/src/main/kotlin/org/recall/mic/MicState.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ import kotlinx.coroutines.flow.asStateFlow
88
// Shared tag for UI-state-change logging; filter with `adb logcat -s recall-ui:I`.
99
const val UI_LOG = "recall-ui"
1010

11+
/** Mic-init failure — distinct so the status can't blame the network for it. */
12+
internal class MicUnavailableException(
13+
message: String,
14+
) : Exception(message)
15+
16+
/**
17+
* What a FAILED streaming attempt says about the microphone.
18+
*
19+
* Only a mic failure says anything about the mic. Anything else — a refused
20+
* connect, a dropped socket — means the attempt never got as far as opening it,
21+
* and reports nothing either way.
22+
*
23+
* ⚠ This used to be `e !is MicUnavailableException`, which read a network failure
24+
* as PROOF THE MIC IS FINE and cleared a real fault. The order is what makes that
25+
* wrong: the socket connects BEFORE the mic opens, so during a household pause —
26+
* when the host's listener is closed — every attempt fails on the connect, and a
27+
* phone whose microphone was genuinely broken kept reporting micOk=true. That is
28+
* exactly what happened for nine hours on 2026-09-06: the app retried every two
29+
* seconds, could not open AudioRecord, and the fleet check built to catch a dead
30+
* mic (#887) called it healthy throughout.
31+
*
32+
* `true` is only ever written where the mic ACTUALLY OPENED.
33+
*/
34+
internal fun micOkAfter(previous: Boolean, failure: Throwable): Boolean =
35+
if (failure is MicUnavailableException) false else previous
36+
1137
/**
1238
* Live streaming state published by [StreamService] and observed by the UI. Same
1339
* process, so this is just shared in-memory state (StateFlow is thread-safe) — no

android/app/src/main/kotlin/org/recall/mic/StreamService.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ class StreamService : Service() {
237237
// without touching the notification (a zombie must not re-post one
238238
// after Stop) and without a pointless network call.
239239
if (!running) break
240-
MicState.setMicOk(e !is MicUnavailableException)
240+
MicState.setMicOk(micOkAfter(MicState.micOk.value, e))
241241
if (e is MicUnavailableException) {
242242
// Blaming the network would send whoever reads it debugging the
243243
// wrong thing — the connect succeeded; the microphone didn't.
@@ -350,11 +350,6 @@ class StreamService : Service() {
350350
)
351351
}
352352

353-
/** Mic-init failure — distinct so the status can't blame the network for it. */
354-
private class MicUnavailableException(
355-
message: String,
356-
) : Exception(message)
357-
358353
/**
359354
* Enter the foreground with the microphone type. Returns false if the OS
360355
* refuses (a mic-type start from the background — e.g. BOOT_COMPLETED — throws
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package org.recall.mic
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
import java.io.IOException
7+
8+
/**
9+
* What a failed streaming attempt is allowed to conclude about the microphone.
10+
*
11+
* ⚠ This exists because the answer was inverted for a network failure, and that
12+
* inversion hid a dead microphone for nine hours behind a check built to catch
13+
* exactly that.
14+
*/
15+
class MicOkTest {
16+
@Test
17+
fun `a mic failure marks the mic bad`() {
18+
assertFalse(
19+
micOkAfter(previous = true, failure = MicUnavailableException("no AudioRecord")),
20+
)
21+
}
22+
23+
@Test
24+
fun `a network failure does not clear an earlier mic failure`() {
25+
// ⚠ THE BUG. The socket connects BEFORE the mic opens, so during a
26+
// household pause — listener closed — every attempt fails on the connect.
27+
// Reading that as "the mic is fine" is what kept micOk=true through nine
28+
// hours of a phone that could not open AudioRecord.
29+
assertFalse(micOkAfter(previous = false, failure = IOException("connection refused")))
30+
}
31+
32+
@Test
33+
fun `a network failure does not invent a mic failure either`() {
34+
// It says nothing in either direction: the attempt never reached the mic.
35+
assertTrue(micOkAfter(previous = true, failure = IOException("connection refused")))
36+
}
37+
38+
@Test
39+
fun `a mic failure is sticky across later network failures`() {
40+
// The retry loop is what made this matter: one bad open followed by a
41+
// hundred refused connects must still report a bad mic.
42+
var ok = micOkAfter(previous = true, failure = MicUnavailableException("no AudioRecord"))
43+
repeat(100) { ok = micOkAfter(previous = ok, failure = IOException("connection refused")) }
44+
assertFalse(ok)
45+
}
46+
}

0 commit comments

Comments
 (0)