Improves stability and permission checks for voice calls when the app is in the background.

This commit is contained in:
Vitor Pamplona
2026-04-14 10:19:47 -04:00
parent 500bc8e4e7
commit 887e0bf302
3 changed files with 37 additions and 21 deletions
@@ -26,6 +26,7 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
@@ -59,6 +60,7 @@ import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "CallController" private const val TAG = "CallController"
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
@Stable
class CallController( class CallController(
private val context: Context, private val context: Context,
val callManager: CallManager, val callManager: CallManager,
@@ -656,11 +658,13 @@ class CallController(
try { try {
val peerName = callManager.currentPeerPubKey() ?: "" val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = mediaManager.isVideoEnabled.value val isVideo = mediaManager.isVideoEnabled.value
val isRinging = callManager.state.value is CallState.IncomingCall || callManager.state.value is CallState.Offering
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) putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
putExtra(CallForegroundService.EXTRA_IS_RINGING, isRinging)
} }
context.startForegroundService(intent) context.startForegroundService(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -672,10 +676,14 @@ class CallController(
if (!foregroundServiceStarted) return if (!foregroundServiceStarted) return
try { try {
val peerName = callManager.currentPeerPubKey() ?: "" val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = mediaManager.isVideoEnabled.value
val isRinging = callManager.state.value is CallState.IncomingCall || callManager.state.value is CallState.Offering
val intent = val intent =
Intent(context, CallForegroundService::class.java).apply { Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_UPDATE action = CallForegroundService.ACTION_UPDATE
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
putExtra(CallForegroundService.EXTRA_IS_RINGING, isRinging)
} }
context.startService(intent) context.startService(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -50,6 +50,7 @@ class CallForegroundService : Service() {
const val EXTRA_PEER_NAME = "peer_name" const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video" const val EXTRA_IS_VIDEO = "is_video"
const val EXTRA_STATUS_TEXT = "status_text" const val EXTRA_STATUS_TEXT = "status_text"
const val EXTRA_IS_RINGING = "is_ringing"
private const val HANGUP_REQUEST_CODE = 0x70001 private const val HANGUP_REQUEST_CODE = 0x70001
} }
@@ -66,26 +67,31 @@ class CallForegroundService : Service() {
startId: Int, startId: Int,
): Int { ): Int {
when (intent?.action) { when (intent?.action) {
ACTION_START -> { ACTION_START, ACTION_UPDATE -> {
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 isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
val isRinging = intent.getBooleanExtra(EXTRA_IS_RINGING, false)
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT) val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
val notification = buildNotification(peerName, statusText) val notification = buildNotification(peerName, statusText)
val hasAudioPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
val hasCameraPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
try { try {
val fgsType = val fgsType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
if (hasAudioPermission) { if (!isRinging) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE val hasAudioPermission =
} ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
if (isVideo && hasCameraPermission) { PackageManager.PERMISSION_GRANTED
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA val hasCameraPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (hasAudioPermission) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (isVideo && hasCameraPermission) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
} }
type type
} else { } else {
@@ -104,19 +110,13 @@ class CallForegroundService : Service() {
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, fallbackType) ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, fallbackType)
} catch (e2: Exception) { } catch (e2: Exception) {
Log.e(TAG, "Foreground service start failed entirely", e2) Log.e(TAG, "Foreground service start failed entirely", e2)
stopSelf() if (intent.action == ACTION_START) {
stopSelf()
}
} }
} }
} }
ACTION_UPDATE -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
val notification = buildNotification(peerName, statusText)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, notification)
}
ACTION_STOP -> { ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
@@ -21,19 +21,24 @@
package com.vitorpamplona.amethyst.commons.call package com.vitorpamplona.amethyst.commons.call
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
@Stable
@Immutable @Immutable
sealed interface CallState { sealed interface CallState {
@Stable
data object Idle : CallState data object Idle : CallState
@Stable
data class Offering( data class Offering(
val callId: String, val callId: String,
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
) : CallState ) : CallState
@Stable
data class IncomingCall( data class IncomingCall(
val callId: String, val callId: String,
val callerPubKey: HexKey, val callerPubKey: HexKey,
@@ -42,6 +47,7 @@ sealed interface CallState {
val sdpOffer: String, val sdpOffer: String,
) : CallState ) : CallState
@Stable
data class Connecting( data class Connecting(
val callId: String, val callId: String,
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,
@@ -49,6 +55,7 @@ sealed interface CallState {
val pendingPeerPubKeys: Set<HexKey> = emptySet(), val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState ) : CallState
@Stable
data class Connected( data class Connected(
val callId: String, val callId: String,
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,
@@ -60,6 +67,7 @@ sealed interface CallState {
val allPeerPubKeys: Set<HexKey> get() = peerPubKeys + pendingPeerPubKeys val allPeerPubKeys: Set<HexKey> get() = peerPubKeys + pendingPeerPubKeys
} }
@Stable
data class Ended( data class Ended(
val callId: String, val callId: String,
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,