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
This commit is contained in:
Claude
2026-04-03 00:13:55 +00:00
parent 70b205326c
commit 6946a5a263
5 changed files with 64 additions and 16 deletions
+6 -2
View File
@@ -14,8 +14,9 @@
</queries> </queries>
<!-- Doesn't require a camera --> <!-- Doesn't require a camera or microphone -->
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<!-- To connect with relays --> <!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@@ -40,10 +41,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls --> <!-- Phone calls -->
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Keeps screen on while playing videos --> <!-- Keeps screen on while playing videos -->
@@ -243,7 +247,7 @@
<service <service
android:name=".service.call.CallForegroundService" android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone" android:foregroundServiceType="microphone|camera"
android:stopWithTask="true" android:stopWithTask="true"
android:exported="false" /> android:exported="false" />
@@ -20,10 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.service.call package com.vitorpamplona.amethyst.service.call
import android.Manifest
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
@@ -39,6 +41,7 @@ import android.os.PowerManager
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -63,6 +66,14 @@ class CallAudioManager(
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow() val audioRoute: StateFlow<AudioRoute> = _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) private val _isBluetoothAvailable = MutableStateFlow(false)
val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow() val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow()
@@ -115,13 +126,18 @@ class CallAudioManager(
previousAudioMode = audioManager.mode previousAudioMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
_isBluetoothAvailable.value = hasBluetoothDevice() if (hasBluetoothPermission()) {
if (_isBluetoothAvailable.value) { _isBluetoothAvailable.value = hasBluetoothDevice()
startBluetoothSco() if (_isBluetoothAvailable.value) {
startBluetoothSco()
} else {
routeToEarpiece()
}
registerBluetoothScoReceiver()
} else { } else {
_isBluetoothAvailable.value = false
routeToEarpiece() routeToEarpiece()
} }
registerBluetoothScoReceiver()
} }
fun restoreAudioMode() { fun restoreAudioMode() {
@@ -220,6 +236,7 @@ class CallAudioManager(
} }
private fun routeToBluetooth() { private fun routeToBluetooth() {
if (!hasBluetoothPermission()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val btDevice = val btDevice =
audioManager audioManager
@@ -461,10 +461,12 @@ class CallController(
private fun startForegroundService() { private fun startForegroundService() {
try { try {
val peerName = callManager.currentPeerPubKey() ?: "" val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = _isVideoEnabled.value
val intent = val intent =
Intent(context, CallForegroundService::class.java).apply { Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_START action = CallForegroundService.ACTION_START
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
} }
context.startForegroundService(intent) context.startForegroundService(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -45,6 +45,7 @@ class CallForegroundService : Service() {
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START" const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP" const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name" const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video"
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -62,14 +63,25 @@ class CallForegroundService : Service() {
when (intent?.action) { when (intent?.action) {
ACTION_START -> { ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
val notification = buildNotification(peerName) val notification = buildNotification(peerName)
val hasAudioPermission = val hasAudioPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
val hasCameraPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
try { try {
val fgsType = val fgsType =
if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE 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 { } else {
0 0
} }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -43,30 +44,42 @@ fun hasCallPermissions(
return true return true
} }
fun buildCallPermissions(isVideo: Boolean): Array<String> {
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 @Composable
fun rememberCallWithPermission( fun rememberCallWithPermission(
context: Context, context: Context,
isVideo: Boolean = false, isVideo: Boolean = false,
onCall: () -> Unit, onCall: () -> Unit,
): () -> Unit { ): () -> Unit {
val permissions = val permissions = remember(isVideo) { buildCallPermissions(isVideo) }
if (isVideo) {
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
} else {
arrayOf(Manifest.permission.RECORD_AUDIO)
}
val launcher = val launcher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { results -> ) { results ->
val allGranted = results.values.all { it } // Bluetooth is optional — proceed if core permissions are granted
if (allGranted) onCall() if (hasCallPermissions(context, isVideo)) onCall()
} }
return remember(onCall, isVideo) { return remember(onCall, isVideo) {
{ {
if (hasCallPermissions(context, 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() onCall()
} else { } else {
launcher.launch(permissions) launcher.launch(permissions)