From e885192f51357c900406744cfbef1378bb70e1eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 18:49:26 +0000 Subject: [PATCH] fix: surface call-permission denial instead of silent failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- .../amethyst/ui/call/CallPermissions.kt | 90 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 5 ++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt index e1bedfb007..ef88edd000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -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 { 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)) + } + }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0438aefc12..d4dcea91c5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1172,6 +1172,11 @@ Failed to start call Failed to accept call Failed to create call session + Permission needed + Amethyst needs microphone access to start a voice call. Please enable it in the app settings. + Amethyst needs camera and microphone access to start a video call. Please enable them in the app settings. + Open settings + Cancel Call Settings Enable voice and video calls When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored.