From ea96dc9fb9b656c67ce2fe12b28f7c8116eed9dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 18:35:59 +0000 Subject: [PATCH 1/5] feat: disable outgoing video when phone is near ear during calls Uses the proximity sensor to detect when the phone is held to the ear and automatically pauses the camera/video track. Video resumes when the phone is moved away, but only if the user had video enabled. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/service/call/CallAudioManager.kt | 36 +++++++++++++++++++ .../amethyst/service/call/CallController.kt | 20 +++++++++++ 2 files changed, 56 insertions(+) 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..171f53243 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() @@ -137,11 +160,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() { 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..b33875ac1 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 @@ -93,6 +93,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) } @@ -126,6 +129,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( @@ -323,6 +342,7 @@ class CallController( _isAudioMuted.value = false _isVideoEnabled.value = false _isRemoteVideoActive.value = false + videoPausedByProximity = false webRtcSession?.dispose() webRtcSession = null remoteDescriptionSet.set(false) From 0f533ea43aa491fea7ac3d4d8def95884e91b9ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 18:42:43 +0000 Subject: [PATCH 2/5] fix: guard against stale SDP answers in wrong signaling state Our own renegotiation answers can echo back through the relay and get processed as if from the peer. Check that the peer connection is in HAVE_LOCAL_OFFER state before applying a remote answer SDP, preventing the "Called in wrong state: stable" error. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/service/call/CallController.kt | 15 +++++++++++++-- .../amethyst/service/call/WebRtcCallSession.kt | 2 ++ 2 files changed, 15 insertions(+), 2 deletions(-) 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 b33875ac1..8ffcef6e6 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 @@ -40,6 +40,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 @@ -228,8 +229,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() } 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) } From cee43bda2e8f919b72de95c734e8dd6938c8d22d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 18:54:54 +0000 Subject: [PATCH 3/5] fix: use setCommunicationDevice API for audio routing on Android 12+ AudioManager.isSpeakerphoneOn is deprecated since API 31 and silently ignored on modern devices. Switch to setCommunicationDevice() which properly routes call audio to speaker, earpiece, or Bluetooth on Android 12+, with fallback to the legacy API on older versions. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/service/call/CallAudioManager.kt | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) 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 171f53243..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 @@ -127,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 } @@ -189,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 = From 0a7577dc21aa5d84562ac0a24468998ce87cccaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 19:09:40 +0000 Subject: [PATCH 4/5] fix: show incoming call notification when app is in background When the app is alive but backgrounded, the relay subscription delivers the call event first, marking it as consumed. The push notification path then skips showing the notification because the event is already consumed. This left the phone ringing with no notification and no way to bring the app to the foreground. Now CallController shows the call notification directly when entering IncomingCall state, regardless of how the event was received. The notification's fullScreenIntent brings the app to the foreground. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/service/call/CallController.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 8ffcef6e6..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 @@ -105,6 +108,7 @@ class CallController( when (state) { is CallState.IncomingCall -> { audioManager.startRinging() + showIncomingCallNotification(state.callerPubKey) } is CallState.Offering -> { @@ -448,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, + ) + } } From 293901a87e142be1bd31fcac64b1020a7ead7477 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 19:16:50 +0000 Subject: [PATCH 5/5] fix: add system bar insets to call screen elements Call screen elements were too close to the screen edges because they didn't account for status bar and navigation bar insets. Apply WindowInsets.statusBars and WindowInsets.navigationBars padding to all call screen layouts while keeping video backgrounds full-bleed. https://claude.ai/code/session_014espsysob7MrE8X8e75jFX --- .../amethyst/ui/call/CallScreen.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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(