fix: surface call-permission denial instead of silent failure

When the user denied RECORD_AUDIO or CAMERA for a DM call, rememberCallWithPermission's
launcher callback only re-checked permissions and called onCall() on success — the denial
branch was empty. On the next button press Android skips the system dialog (permanently
denied) and immediately returns deny, so the call button appeared dead with no UI feedback.

Now the denial path opens an AlertDialog with an "Open settings" deep-link, matching the
NestActionBar pattern. Also splits the optional BLUETOOTH_CONNECT request onto its own
launcher so its result callback no longer triggers a second onCall().
This commit is contained in:
Claude
2026-05-16 18:49:26 +00:00
parent f108ba9050
commit e885192f51
2 changed files with 91 additions and 4 deletions
@@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.ui.call
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
fun hasPermission(
context: Context,
@@ -55,6 +66,17 @@ fun buildCallPermissions(isVideo: Boolean): Array<String> {
return permissions.toTypedArray()
}
fun openAppSettings(context: Context) {
runCatching {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
)
}
}
@Composable
fun rememberCallWithPermission(
context: Context,
@@ -62,23 +84,50 @@ fun rememberCallWithPermission(
onCall: () -> Unit,
): () -> Unit {
val permissions = remember(isVideo) { buildCallPermissions(isVideo) }
var showDeniedDialog by remember { mutableStateOf(false) }
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { _ ->
// Bluetooth is optional — proceed if core permissions are granted
if (hasCallPermissions(context, isVideo)) onCall()
// Bluetooth is optional — proceed if core permissions are granted.
// If core permissions are still denied (including the silent
// permanently-denied case where Android skips the dialog), surface
// the deep-link dialog instead of failing silently.
if (hasCallPermissions(context, isVideo)) {
onCall()
} else {
showDeniedDialog = true
}
}
val bluetoothLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { _ ->
// BT result is best-effort and never blocks the call.
}
if (showDeniedDialog) {
CallPermissionDeniedDialog(
isVideo = isVideo,
onDismiss = { showDeniedDialog = false },
onOpenSettings = {
showDeniedDialog = false
openAppSettings(context)
},
)
}
return remember(onCall, isVideo) {
{
if (hasCallPermissions(context, isVideo)) {
// Core permissions granted; still request BT if missing
// Core permissions granted; request BT separately so the
// result callback doesn't double-fire onCall().
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
!hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT)
) {
launcher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
bluetoothLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT))
}
onCall()
} else {
@@ -87,3 +136,36 @@ fun rememberCallWithPermission(
}
}
}
@Composable
private fun CallPermissionDeniedDialog(
isVideo: Boolean,
onDismiss: () -> Unit,
onOpenSettings: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.call_permission_denied_title)) },
text = {
Text(
stringRes(
if (isVideo) {
R.string.call_permission_denied_video
} else {
R.string.call_permission_denied_voice
},
),
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text(stringRes(R.string.call_permission_denied_open_settings))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.call_permission_denied_cancel))
}
},
)
}
+5
View File
@@ -1172,6 +1172,11 @@
<string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
<string name="call_permission_denied_title">Permission needed</string>
<string name="call_permission_denied_voice">Amethyst needs microphone access to start a voice call. Please enable it in the app settings.</string>
<string name="call_permission_denied_video">Amethyst needs camera and microphone access to start a video call. Please enable them in the app settings.</string>
<string name="call_permission_denied_open_settings">Open settings</string>
<string name="call_permission_denied_cancel">Cancel</string>
<string name="call_settings">Call Settings</string>
<string name="call_settings_enable_calls">Enable voice and video calls</string>
<string name="call_settings_enable_calls_description">When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored.</string>