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.