feat: localized strings, PiP support, and ICE candidate fix

Localized strings:
- Added 28 string resources for all call UI text in strings.xml
- Replaced hardcoded strings in CallScreen, CallForegroundService,
  NotificationUtils, and RenderRoomTopBar with stringRes() calls
- Technical error messages in CallController remain English (no
  composable context available)

Picture-in-Picture:
- EnterPipOnLeave composable observes lifecycle and enters PiP
  mode when app goes to background during active call
- MainActivity manifest updated with supportsPictureInPicture and
  smallestScreenSize configChange
- 9:16 aspect ratio for portrait PiP window

ICE candidate fix (from logcat diagnosis):
- acceptIncomingCall() no longer clears pendingIceCandidates at
  start — candidates buffered while ringing were being wiped
  before flushPendingIceCandidates() could apply them
- This was causing "Flushing 0 buffered ICE candidates" on callee

StrictMode fix:
- initiateCall/acceptIncomingCall now run WebRTC session creation
  on Dispatchers.IO to avoid DiskReadViolation from native lib
  loading on main thread

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-02 02:57:15 +00:00
parent 24d7fb1a92
commit 0bd26bcacd
7 changed files with 150 additions and 72 deletions
+2 -1
View File
@@ -80,7 +80,8 @@
android:exported="true"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -32,10 +32,12 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.webrtc.IceCandidate
import org.webrtc.MediaStream
import org.webrtc.SessionDescription
@@ -113,34 +115,36 @@ class CallController(
peerPubKey: String,
callType: CallType,
) {
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
remoteDescriptionSet = false
pendingIceCandidates.clear()
scope.launch {
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
remoteDescriptionSet = false
pendingIceCandidates.clear()
try {
createWebRtcSession()
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to start call: ${e.message}"
return
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return
try {
withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to start call: ${e.message}"
return@launch
}
session.addAudioTrack()
if (callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return@launch
}
session.createOffer { sdp ->
scope.launch {
callManager.initiateCall(peerPubKey, callType, callId, sdp.description)
session.addAudioTrack()
if (callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
}
session.createOffer { sdp ->
scope.launch {
callManager.initiateCall(peerPubKey, callType, callId, sdp.description)
}
}
}
}
@@ -149,36 +153,38 @@ class CallController(
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
_errorMessage.value = null
remoteDescriptionSet = false
pendingIceCandidates.clear()
scope.launch {
_errorMessage.value = null
remoteDescriptionSet = false
// Don't clear pendingIceCandidates here — they were buffered while ringing
try {
createWebRtcSession()
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
return
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return
try {
withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
return@launch
}
session.addAudioTrack()
if (state.callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return@launch
}
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
flushPendingIceCandidates()
session.addAudioTrack()
if (state.callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
}
session.createAnswer { sdp ->
scope.launch {
callManager.acceptCall(sdp.description)
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
flushPendingIceCandidates()
session.createAnswer { sdp ->
scope.launch {
callManager.acceptCall(sdp.description)
}
}
}
}
@@ -81,10 +81,10 @@ class CallForegroundService : Service() {
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
getString(R.string.call_ongoing),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Ongoing call notification"
description = getString(R.string.call_ongoing_description)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
@@ -94,7 +94,7 @@ class CallForegroundService : Service() {
NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("Call with $peerName")
.setContentText(getString(R.string.call_with, peerName))
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.build()
@@ -519,10 +519,10 @@ object NotificationUtils {
callChannel =
NotificationChannel(
CALL_CHANNEL_ID,
"Incoming calls",
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Notifications for incoming voice and video calls"
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
}
val notificationManager: NotificationManager =
@@ -573,7 +573,7 @@ object NotificationUtils {
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle("Incoming call")
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
@@ -584,8 +584,8 @@ object NotificationUtils {
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, "Reject", contentPendingIntent)
.addAction(R.drawable.amethyst, "Accept", fullScreenPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallController
@@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -98,6 +100,7 @@ fun CallScreen(
}
KeepScreenOn()
EnterPipOnLeave(callState)
Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) {
@@ -108,7 +111,7 @@ fun CallScreen(
is CallState.Offering -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Calling...",
statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
@@ -131,7 +134,7 @@ fun CallScreen(
is CallState.Connecting -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Connecting...",
statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
@@ -152,7 +155,7 @@ fun CallScreen(
is CallState.Ended -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Call ended",
statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
)
@@ -164,7 +167,7 @@ fun CallScreen(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
Text(
"Dismiss",
stringRes(R.string.call_dismiss),
modifier =
Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
@@ -225,7 +228,7 @@ private fun CallInProgressUI(
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
contentDescription = stringRes(R.string.call_hangup),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
@@ -270,7 +273,14 @@ private fun IncomingCallUI(
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Incoming ${callType.value} call...",
text =
stringRes(
if (callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO) {
R.string.call_incoming_video
} else {
R.string.call_incoming_voice
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
@@ -286,7 +296,7 @@ private fun IncomingCallUI(
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Reject",
contentDescription = stringRes(R.string.call_reject),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
@@ -299,7 +309,7 @@ private fun IncomingCallUI(
) {
Icon(
Icons.Default.Call,
contentDescription = "Accept",
contentDescription = stringRes(R.string.call_accept),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
@@ -428,7 +438,7 @@ private fun ConnectedCallUI(
) {
Icon(
imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (isAudioMuted) "Unmute" else "Mute",
contentDescription = stringRes(if (isAudioMuted) R.string.call_unmute else R.string.call_mute),
tint = if (isAudioMuted) Color.Red else Color.White,
modifier = Modifier.size(28.dp),
)
@@ -439,7 +449,7 @@ private fun ConnectedCallUI(
) {
Icon(
imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff,
contentDescription = if (isVideoEnabled) "Camera off" else "Camera on",
contentDescription = stringRes(if (isVideoEnabled) R.string.call_camera_off else R.string.call_camera_on),
tint = if (!isVideoEnabled) Color.Red else Color.White,
modifier = Modifier.size(28.dp),
)
@@ -450,7 +460,7 @@ private fun ConnectedCallUI(
) {
Icon(
imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
contentDescription = if (isSpeakerOn) "Earpiece" else "Speaker",
contentDescription = stringRes(if (isSpeakerOn) R.string.call_earpiece else R.string.call_speaker),
tint = if (isSpeakerOn) Color.Cyan else Color.White,
modifier = Modifier.size(28.dp),
)
@@ -465,7 +475,7 @@ private fun ConnectedCallUI(
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
contentDescription = stringRes(R.string.call_hangup),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
@@ -515,3 +525,36 @@ private fun KeepScreenOn() {
}
}
}
@Composable
private fun EnterPipOnLeave(callState: CallState) {
val context = LocalContext.current
val activity = context as? android.app.Activity ?: return
val isActiveCall =
callState is CallState.Connected ||
callState is CallState.Connecting ||
callState is CallState.Offering
if (isActiveCall && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer =
object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onStop(owner: androidx.lifecycle.LifecycleOwner) {
try {
val params =
android.app.PictureInPictureParams
.Builder()
.setAspectRatio(android.util.Rational(9, 16))
.build()
activity.enterPictureInPictureMode(params)
} catch (_: Exception) {
// PiP not supported or activity not in correct state
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
}
@@ -99,7 +99,7 @@ fun RenderRoomTopBar(
) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = "Video call",
contentDescription = stringRes(R.string.call_video),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
@@ -113,7 +113,7 @@ fun RenderRoomTopBar(
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Voice call",
contentDescription = stringRes(R.string.call_voice),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
+28
View File
@@ -771,6 +771,34 @@
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
<string name="app_notification_chess_summary">Chess updates</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Incoming calls</string>
<string name="app_notification_calls_channel_description">Notifications for incoming voice and video calls</string>
<string name="call_incoming">Incoming call</string>
<string name="call_incoming_voice">Incoming voice call\u2026</string>
<string name="call_incoming_video">Incoming video call\u2026</string>
<string name="call_calling">Calling\u2026</string>
<string name="call_connecting">Connecting\u2026</string>
<string name="call_ended">Call ended</string>
<string name="call_with">Call with %1$s</string>
<string name="call_ongoing">Calls</string>
<string name="call_ongoing_description">Ongoing call notification</string>
<string name="call_hangup">Hang up</string>
<string name="call_accept">Accept</string>
<string name="call_reject">Reject</string>
<string name="call_dismiss">Dismiss</string>
<string name="call_mute">Mute</string>
<string name="call_unmute">Unmute</string>
<string name="call_camera_on">Camera on</string>
<string name="call_camera_off">Camera off</string>
<string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string>
<string name="call_voice">Voice call</string>
<string name="call_video">Video call</string>
<string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>