A call asks for the microphone, which nothing on that path ever did

A call is a microphone the moment it starts, and no call path asked for
one. `CAMERA` was requested for video calls; `RECORD_AUDIO` was requested
only by the voice-note button. So anybody who had never recorded a voice
note had never granted it, and every call they placed or answered hit:

  SecurityException: Starting FGS with type microphone ...
  requires android.permission.RECORD_AUDIO

The throw is caught and logged, and the call then carries on with no
microphone. The far end hears nothing and it ends looking like the call
"just went away", which is exactly how it has been described. A clean
install placing its first call reproduces it every time.

`rememberMicThen` asks first, and a refusal is respected rather than
worked around: the call is not placed, because a call nobody can hear is
worse than no call. Placing from a conversation and both answer paths go
through it.

Also: an error raised anywhere but Home was invisible. `phase.error` was
drawn inside `SignedIn`, which is the Home tab and nothing else, so a
failure on Stories, Messages, Calls, Files, Wallet or Account explained
itself to an empty room. It moves up beside the call banner, which is
already there for the same reason: it belongs wherever the person is
standing when it happens.

Verified on the emulator with RECORD_AUDIO denied: tapping Call now
raises the system prompt where it used to proceed silently, and after
granting, the call runs with no SecurityException.

1474 unit tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-18 07:12:23 -04:00
co-authored by Claude Opus 5
parent 611a16d0b3
commit 91a46b4d2a
3 changed files with 91 additions and 15 deletions
@@ -254,7 +254,7 @@ fun LiveCallScreen(
isPrimary = true,
tint = Palette.Teal,
iconColor = Color.White,
run = model::acceptCall,
run = rememberMicThen { model.acceptCall() },
),
)
}
@@ -452,6 +452,52 @@ private fun VideoSurface(
/**
* A "start a video call" action that asks for the camera first.
*/
/**
* Ask for the microphone, then do the thing that needs it.
*
* A call is a microphone the moment it starts, and nothing on the call path
* ever asked for one. `CAMERA` was requested for video, `RECORD_AUDIO` only by
* the voice-note button, so anybody who had never recorded a voice note had
* never granted it. The call then tried to declare its foreground service with
* type `microphone`, which Android refuses without that permission:
*
* SecurityException: Starting FGS with type microphone ...
* requires android.permission.RECORD_AUDIO
*
* The throw is caught and logged, and the call carries on with no microphone,
* so the far end hears nothing and the call ends looking like it "just went
* away". Observed on a clean install placing its first call.
*
* Asking first is the whole fix. A refusal is respected: the call is not
* placed, because a call nobody can hear is worse than no call.
*/
@Composable
fun rememberMicThen(run: () -> Unit): () -> Unit {
val context = androidx.compose.ui.platform.LocalContext.current
val pending = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val ask =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted ->
val wanted = pending.value
pending.value = false
if (wanted && granted) run()
}
return {
val has =
androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (has) {
run()
} else {
pending.value = true
ask.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
}
@Composable
fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -1664,11 +1664,14 @@ fun ColumnScope.ChatScreen(
modifier = Modifier.testTag("start-video-call"),
onClick = { videoCall(chat.peerId) },
)
// Through the microphone ask: a call is a microphone, and this
// path never requested one.
val placeCall = rememberMicThen { model.requestCall(chat.peerId) }
PortalHeaderIcon(
icon = KitIcon.Call,
contentDescription = "Call",
modifier = Modifier.testTag("start-call"),
onClick = { model.requestCall(chat.peerId) },
onClick = placeCall,
)
}
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
@@ -3153,7 +3156,7 @@ fun ColumnScope.CallBanner(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (call.state == CallManager.State.RINGING && call.incoming) {
Button(
onClick = model::acceptCall,
onClick = rememberMicThen { model.acceptCall() },
modifier = Modifier.testTag("accept-call"),
) { Text("Answer") }
TextButton(
@@ -272,7 +272,19 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// NOT scrollable.
is MessengerViewModel.Phase.SignedIn -> {
Column(modifier = flush) {
// Above the screen switch, not inside one branch.
// Above the screen switch, for the same reason the call
// banner is: an error belongs wherever the person is
// standing when it happens.
//
// This used to be drawn inside `SignedIn`, which is the
// HOME tab and nothing else, so anything that failed on
// any other screen said why to an empty room. Posting a
// story is the clearest case: it refuses when the audience
// is empty, sets a sentence explaining exactly that, and
// the person watching the Stories tab saw the sheet close
// and nothing appear. "It just doesn't work" is what that
// looks like from the outside, six screens out of seven.
ScreenError(current.error)
CallBanner(model, current)
// §6.30, another of this account's devices offering the
// call it is on. Above the screen switch for the same
@@ -855,17 +867,6 @@ internal fun ColumnScope.SignedIn(
)
}
phase.error?.let {
PortalBody {
Text(
it,
color = MaterialTheme.colorScheme.error,
fontSize = 14.sp,
modifier = Modifier.testTag("error"),
)
}
}
// A call refused because the peer's keys changed is dealt with here, where
// it failed (ios#73). This first opened the conversation instead, which is
// the same gap one screen along: a call placed from the Calls tab, or
@@ -907,6 +908,32 @@ internal fun ColumnScope.SignedIn(
}
}
/**
* Whatever just went wrong, wherever the person happens to be.
*
* Drawn once, above the screen switch. Tagged `error` because that is the tag
* every test already looks for.
*/
@Composable
private fun ScreenError(message: String?) {
if (message == null) return
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
.padding(horizontal = Space.md, vertical = Space.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
message,
color = MaterialTheme.colorScheme.error,
fontSize = 14.sp,
modifier = Modifier.testTag("error"),
)
}
}
/**
* One icon-sized action on the trailing edge of the bar.
*/