From 6946a5a2637cd6707aca87f649e944938ee37022 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:13:55 +0000 Subject: [PATCH] fix: add missing call permissions for all supported Android versions - Add MODIFY_AUDIO_SETTINGS for audio routing (speaker, earpiece, BT SCO) - Add BLUETOOTH permission for API 26-30 (maxSdkVersion=30) - Request BLUETOOTH_CONNECT at runtime on API 31+ for BT audio - Add FOREGROUND_SERVICE_CAMERA for video calls on API 34+ - Update foreground service type to microphone|camera for video calls - Guard Bluetooth operations with permission check in CallAudioManager - Add uses-feature for microphone with required=false https://claude.ai/code/session_01LR8NmFGdMoDTVcfKjL7HWR --- amethyst/src/main/AndroidManifest.xml | 8 +++-- .../amethyst/service/call/CallAudioManager.kt | 25 +++++++++++++--- .../amethyst/service/call/CallController.kt | 2 ++ .../service/call/CallForegroundService.kt | 16 ++++++++-- .../amethyst/ui/call/CallPermissions.kt | 29 ++++++++++++++----- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d03cf083a0..796a4b8b7a 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -14,8 +14,9 @@ - + + @@ -40,10 +41,13 @@ + + + @@ -243,7 +247,7 @@ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index 40c81c0487..0879145506 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.service.call +import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -39,6 +41,7 @@ import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -63,6 +66,14 @@ class CallAudioManager( private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) val audioRoute: StateFlow = _audioRoute.asStateFlow() + private fun hasBluetoothPermission(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED + } else { + true + } + private val _isBluetoothAvailable = MutableStateFlow(false) val isBluetoothAvailable: StateFlow = _isBluetoothAvailable.asStateFlow() @@ -115,13 +126,18 @@ class CallAudioManager( previousAudioMode = audioManager.mode audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - _isBluetoothAvailable.value = hasBluetoothDevice() - if (_isBluetoothAvailable.value) { - startBluetoothSco() + if (hasBluetoothPermission()) { + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_isBluetoothAvailable.value) { + startBluetoothSco() + } else { + routeToEarpiece() + } + registerBluetoothScoReceiver() } else { + _isBluetoothAvailable.value = false routeToEarpiece() } - registerBluetoothScoReceiver() } fun restoreAudioMode() { @@ -220,6 +236,7 @@ class CallAudioManager( } private fun routeToBluetooth() { + if (!hasBluetoothPermission()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val btDevice = audioManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index e8b35e8163..c841892db1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -461,10 +461,12 @@ class CallController( private fun startForegroundService() { try { val peerName = callManager.currentPeerPubKey() ?: "" + val isVideo = _isVideoEnabled.value val intent = Intent(context, CallForegroundService::class.java).apply { action = CallForegroundService.ACTION_START putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) + putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo) } context.startForegroundService(intent) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 607e48155f..1b0a00a492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -45,6 +45,7 @@ class CallForegroundService : Service() { const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START" const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP" const val EXTRA_PEER_NAME = "peer_name" + const val EXTRA_IS_VIDEO = "is_video" } override fun onBind(intent: Intent?): IBinder? = null @@ -62,14 +63,25 @@ class CallForegroundService : Service() { when (intent?.action) { ACTION_START -> { val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false) val notification = buildNotification(peerName) val hasAudioPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val hasCameraPermission = + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED try { val fgsType = - if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + var type = 0 + if (hasAudioPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + if (isVideo && hasCameraPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA + } + type } else { 0 } 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 841caf9469..50717b1005 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -43,30 +44,42 @@ fun hasCallPermissions( return true } +fun buildCallPermissions(isVideo: Boolean): Array { + val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + if (isVideo) { + permissions.add(Manifest.permission.CAMERA) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions.add(Manifest.permission.BLUETOOTH_CONNECT) + } + return permissions.toTypedArray() +} + @Composable fun rememberCallWithPermission( context: Context, isVideo: Boolean = false, onCall: () -> Unit, ): () -> Unit { - val permissions = - if (isVideo) { - arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) - } else { - arrayOf(Manifest.permission.RECORD_AUDIO) - } + val permissions = remember(isVideo) { buildCallPermissions(isVideo) } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ) { results -> - val allGranted = results.values.all { it } - if (allGranted) onCall() + // Bluetooth is optional — proceed if core permissions are granted + if (hasCallPermissions(context, isVideo)) onCall() } return remember(onCall, isVideo) { { if (hasCallPermissions(context, isVideo)) { + // Core permissions granted; still request BT if missing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + !hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT) + ) { + launcher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) + } onCall() } else { launcher.launch(permissions)