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:
co-authored by
Claude Opus 5
parent
611a16d0b3
commit
91a46b4d2a
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user