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 d703c22d8..40c81c048 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 @@ -24,6 +24,10 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioManager @@ -62,6 +66,25 @@ class CallAudioManager( private val _isBluetoothAvailable = MutableStateFlow(false) val isBluetoothAvailable: StateFlow = _isBluetoothAvailable.asStateFlow() + // Proximity sensor — detects when the phone is held near the ear + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + private val proximitySensor: Sensor? = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY) + private val _isNearEar = MutableStateFlow(false) + val isNearEar: StateFlow = _isNearEar.asStateFlow() + + private val proximityListener = + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val maxRange = event.sensor.maximumRange + _isNearEar.value = event.values[0] < maxRange + } + + override fun onAccuracyChanged( + sensor: Sensor?, + accuracy: Int, + ) {} + } + fun startRinging() { startRingtone() startVibration() @@ -104,7 +127,11 @@ class CallAudioManager( fun restoreAudioMode() { stopBluetoothSco() unregisterBluetoothScoReceiver() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } audioManager.mode = previousAudioMode + @Suppress("DEPRECATION") audioManager.isSpeakerphoneOn = false } @@ -137,11 +164,24 @@ class CallAudioManager( "amethyst:call_proximity", ) proximityWakeLock?.acquire(60 * 60 * 1000L) + registerProximitySensor() } fun releaseProximityWakeLock() { proximityWakeLock?.let { if (it.isHeld) it.release() } proximityWakeLock = null + unregisterProximitySensor() + } + + private fun registerProximitySensor() { + proximitySensor?.let { + sensorManager?.registerListener(proximityListener, it, SensorManager.SENSOR_DELAY_NORMAL) + } + } + + private fun unregisterProximitySensor() { + sensorManager?.unregisterListener(proximityListener) + _isNearEar.value = false } fun release() { @@ -153,17 +193,47 @@ class CallAudioManager( private fun routeToEarpiece() { stopBluetoothSco() - audioManager.isSpeakerphoneOn = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val earpiece = + audioManager + .availableCommunicationDevices + .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE } + earpiece?.let { audioManager.setCommunicationDevice(it) } + } else { + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + } } private fun routeToSpeaker() { stopBluetoothSco() - audioManager.isSpeakerphoneOn = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val speaker = + audioManager + .availableCommunicationDevices + .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } + speaker?.let { audioManager.setCommunicationDevice(it) } + } else { + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = true + } } private fun routeToBluetooth() { - audioManager.isSpeakerphoneOn = false - startBluetoothSco() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val btDevice = + audioManager + .availableCommunicationDevices + .firstOrNull { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + it.type == AudioDeviceInfo.TYPE_BLE_HEADSET + } + btDevice?.let { audioManager.setCommunicationDevice(it) } + } else { + @Suppress("DEPRECATION") + audioManager.isSpeakerphoneOn = false + startBluetoothSco() + } } private fun hasBluetoothDevice(): Boolean = 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 cbdd6f3d9..679b1cdcc 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 @@ -24,7 +24,10 @@ import android.content.Context import android.content.Intent import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.notifications.NotificationUtils +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent @@ -40,6 +43,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.webrtc.IceCandidate +import org.webrtc.PeerConnection import org.webrtc.SessionDescription import org.webrtc.VideoFrame import org.webrtc.VideoSink @@ -93,6 +97,9 @@ class CallController( val audioRoute: StateFlow = audioManager.audioRoute val isBluetoothAvailable: StateFlow = audioManager.isBluetoothAvailable + // Tracks whether video is paused because the phone is near the ear + private var videoPausedByProximity = false + init { callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) } @@ -101,6 +108,7 @@ class CallController( when (state) { is CallState.IncomingCall -> { audioManager.startRinging() + showIncomingCallNotification(state.callerPubKey) } is CallState.Offering -> { @@ -126,6 +134,22 @@ class CallController( } } } + + // Pause outgoing video when the phone is held near the ear + scope.launch { + audioManager.isNearEar.collect { nearEar -> + val session = webRtcSession ?: return@collect + if (nearEar && _isVideoEnabled.value && !videoPausedByProximity) { + videoPausedByProximity = true + session.setVideoEnabled(false) + session.stopCamera() + } else if (!nearEar && videoPausedByProximity) { + videoPausedByProximity = false + session.setVideoEnabled(true) + session.startCamera() + } + } + } } fun initiateCall( @@ -209,8 +233,18 @@ class CallController( } fun onCallAnswerReceived(sdpAnswer: String) { - Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } - webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) + val session = webRtcSession ?: return + val signalingState = session.getSignalingState() + Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, signalingState=$signalingState" } + + // An answer is only valid when we have a pending local offer. + // Ignore stale answers (e.g. our own renegotiation answer echoed back by the relay). + if (signalingState != PeerConnection.SignalingState.HAVE_LOCAL_OFFER) { + Log.d(TAG) { "Ignoring answer in $signalingState state (no pending local offer)" } + return + } + + session.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) flushPendingIceCandidates() } @@ -323,6 +357,7 @@ class CallController( _isAudioMuted.value = false _isVideoEnabled.value = false _isRemoteVideoActive.value = false + videoPausedByProximity = false webRtcSession?.dispose() webRtcSession = null remoteDescriptionSet.set(false) @@ -417,4 +452,17 @@ class CallController( } catch (_: Exception) { } } + + private fun showIncomingCallNotification(callerPubKey: String) { + val callerUser = LocalCache.getUserIfExists(callerPubKey) + val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..." + val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}" + + NotificationUtils.sendCallNotification( + callerName = callerName, + callerBitmap = null, + uri = uri, + applicationContext = context, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 274b39399..071382176 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -263,6 +263,8 @@ class WebRtcCallSession( peerConnection?.addIceCandidate(candidate) } + fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState() + fun setAudioEnabled(enabled: Boolean) { localAudioTrack?.setEnabled(enabled) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 30ed7e3b6..01398aa04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -28,11 +28,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BluetoothAudio @@ -201,7 +205,9 @@ private fun CallInProgressUI( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + .background(MaterialTheme.colorScheme.surface) + .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(WindowInsets.navigationBars), contentAlignment = Alignment.Center, ) { Column( @@ -259,7 +265,9 @@ private fun IncomingCallUI( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + .background(MaterialTheme.colorScheme.surface) + .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(WindowInsets.navigationBars), contentAlignment = Alignment.Center, ) { Column( @@ -387,6 +395,7 @@ private fun ConnectedCallUI( Modifier .size(120.dp, 160.dp) .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.statusBars) .padding(16.dp), mirror = true, ) @@ -432,7 +441,8 @@ private fun ConnectedCallUI( modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 48.dp), + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 16.dp), ) } @@ -441,7 +451,8 @@ private fun ConnectedCallUI( modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Row(