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