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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user