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>
<!-- 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.microphone" android:required="false" />
<!-- To connect with relays -->
<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_MEDIA_PLAYBACK" />
<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" />
<!-- Phone calls -->
<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" />
<!-- Keeps screen on while playing videos -->
@@ -243,7 +247,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone"
android:foregroundServiceType="microphone|camera"
android:stopWithTask="true"
android:exported="false" />
@@ -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> = _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<Boolean> = _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
@@ -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) {
@@ -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
}
@@ -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<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
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)