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:
@@ -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,6 +126,7 @@ class CallAudioManager(
|
|||||||
previousAudioMode = audioManager.mode
|
previousAudioMode = audioManager.mode
|
||||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
|
|
||||||
|
if (hasBluetoothPermission()) {
|
||||||
_isBluetoothAvailable.value = hasBluetoothDevice()
|
_isBluetoothAvailable.value = hasBluetoothDevice()
|
||||||
if (_isBluetoothAvailable.value) {
|
if (_isBluetoothAvailable.value) {
|
||||||
startBluetoothSco()
|
startBluetoothSco()
|
||||||
@@ -122,6 +134,10 @@ class CallAudioManager(
|
|||||||
routeToEarpiece()
|
routeToEarpiece()
|
||||||
}
|
}
|
||||||
registerBluetoothScoReceiver()
|
registerBluetoothScoReceiver()
|
||||||
|
} else {
|
||||||
|
_isBluetoothAvailable.value = false
|
||||||
|
routeToEarpiece()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
+14
-2
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user