Merge pull request #2078 from vitorpamplona/claude/disable-video-near-ear-de4vB

Add proximity sensor support to auto-pause video during calls
This commit is contained in:
Vitor Pamplona
2026-04-02 15:18:53 -04:00
committed by GitHub
4 changed files with 141 additions and 10 deletions
@@ -24,6 +24,10 @@ 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.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
@@ -62,6 +66,25 @@ class CallAudioManager(
private val _isBluetoothAvailable = MutableStateFlow(false) private val _isBluetoothAvailable = MutableStateFlow(false)
val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow() val isBluetoothAvailable: StateFlow<Boolean> = _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<Boolean> = _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() { fun startRinging() {
startRingtone() startRingtone()
startVibration() startVibration()
@@ -104,7 +127,11 @@ class CallAudioManager(
fun restoreAudioMode() { fun restoreAudioMode() {
stopBluetoothSco() stopBluetoothSco()
unregisterBluetoothScoReceiver() unregisterBluetoothScoReceiver()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
}
audioManager.mode = previousAudioMode audioManager.mode = previousAudioMode
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false audioManager.isSpeakerphoneOn = false
} }
@@ -137,11 +164,24 @@ class CallAudioManager(
"amethyst:call_proximity", "amethyst:call_proximity",
) )
proximityWakeLock?.acquire(60 * 60 * 1000L) proximityWakeLock?.acquire(60 * 60 * 1000L)
registerProximitySensor()
} }
fun releaseProximityWakeLock() { fun releaseProximityWakeLock() {
proximityWakeLock?.let { if (it.isHeld) it.release() } proximityWakeLock?.let { if (it.isHeld) it.release() }
proximityWakeLock = null 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() { fun release() {
@@ -153,17 +193,47 @@ class CallAudioManager(
private fun routeToEarpiece() { private fun routeToEarpiece() {
stopBluetoothSco() 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() { private fun routeToSpeaker() {
stopBluetoothSco() 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() { private fun routeToBluetooth() {
audioManager.isSpeakerphoneOn = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
startBluetoothSco() 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 = private fun hasBluetoothDevice(): Boolean =
@@ -24,7 +24,10 @@ import android.content.Context
import android.content.Intent import android.content.Intent
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
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils 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.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
@@ -40,6 +43,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
import org.webrtc.PeerConnection
import org.webrtc.SessionDescription import org.webrtc.SessionDescription
import org.webrtc.VideoFrame import org.webrtc.VideoFrame
import org.webrtc.VideoSink import org.webrtc.VideoSink
@@ -93,6 +97,9 @@ class CallController(
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
// Tracks whether video is paused because the phone is near the ear
private var videoPausedByProximity = false
init { init {
callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) } callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) }
@@ -101,6 +108,7 @@ class CallController(
when (state) { when (state) {
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
audioManager.startRinging() audioManager.startRinging()
showIncomingCallNotification(state.callerPubKey)
} }
is CallState.Offering -> { 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( fun initiateCall(
@@ -209,8 +233,18 @@ class CallController(
} }
fun onCallAnswerReceived(sdpAnswer: String) { fun onCallAnswerReceived(sdpAnswer: String) {
Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } val session = webRtcSession ?: return
webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) 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() flushPendingIceCandidates()
} }
@@ -323,6 +357,7 @@ class CallController(
_isAudioMuted.value = false _isAudioMuted.value = false
_isVideoEnabled.value = false _isVideoEnabled.value = false
_isRemoteVideoActive.value = false _isRemoteVideoActive.value = false
videoPausedByProximity = false
webRtcSession?.dispose() webRtcSession?.dispose()
webRtcSession = null webRtcSession = null
remoteDescriptionSet.set(false) remoteDescriptionSet.set(false)
@@ -417,4 +452,17 @@ class CallController(
} catch (_: Exception) { } 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,
)
}
} }
@@ -263,6 +263,8 @@ class WebRtcCallSession(
peerConnection?.addIceCandidate(candidate) peerConnection?.addIceCandidate(candidate)
} }
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
fun setAudioEnabled(enabled: Boolean) { fun setAudioEnabled(enabled: Boolean) {
localAudioTrack?.setEnabled(enabled) localAudioTrack?.setEnabled(enabled)
} }
@@ -28,11 +28,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothAudio import androidx.compose.material.icons.filled.BluetoothAudio
@@ -201,7 +205,9 @@ private fun CallInProgressUI(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface)
.windowInsetsPadding(WindowInsets.statusBars)
.windowInsetsPadding(WindowInsets.navigationBars),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
@@ -259,7 +265,9 @@ private fun IncomingCallUI(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface)
.windowInsetsPadding(WindowInsets.statusBars)
.windowInsetsPadding(WindowInsets.navigationBars),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
@@ -387,6 +395,7 @@ private fun ConnectedCallUI(
Modifier Modifier
.size(120.dp, 160.dp) .size(120.dp, 160.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp), .padding(16.dp),
mirror = true, mirror = true,
) )
@@ -432,7 +441,8 @@ private fun ConnectedCallUI(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = 48.dp), .windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp),
) )
} }
@@ -441,7 +451,8 @@ private fun ConnectedCallUI(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 48.dp), .windowInsetsPadding(WindowInsets.navigationBars)
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( Row(