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.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<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() {
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 =
@@ -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<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = 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,
)
}
}
@@ -263,6 +263,8 @@ class WebRtcCallSession(
peerConnection?.addIceCandidate(candidate)
}
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
fun setAudioEnabled(enabled: Boolean) {
localAudioTrack?.setEnabled(enabled)
}
@@ -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(