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:exported="true"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Amethyst"> android:theme="@style/Theme.Amethyst">
<intent-filter android:label="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.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
import org.webrtc.MediaStream import org.webrtc.MediaStream
import org.webrtc.SessionDescription import org.webrtc.SessionDescription
@@ -113,34 +115,36 @@ class CallController(
peerPubKey: String, peerPubKey: String,
callType: CallType, callType: CallType,
) { ) {
val callId = UUID.randomUUID().toString() scope.launch {
_errorMessage.value = null val callId = UUID.randomUUID().toString()
remoteDescriptionSet = false _errorMessage.value = null
pendingIceCandidates.clear() remoteDescriptionSet = false
pendingIceCandidates.clear()
try { try {
createWebRtcSession() withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e) Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to start call: ${e.message}" _errorMessage.value = "Failed to start call: ${e.message}"
return return@launch
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return
} }
session.addAudioTrack() val session =
if (callType == CallType.VIDEO) { webRtcSession ?: run {
session.addVideoTrack() _errorMessage.value = "Failed to create WebRTC session"
_localVideoTrack.value = session.getLocalVideoTrack() return@launch
} }
session.createOffer { sdp -> session.addAudioTrack()
scope.launch { if (callType == CallType.VIDEO) {
callManager.initiateCall(peerPubKey, callType, callId, sdp.description) 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 val state = callManager.state.value
if (state !is CallState.IncomingCall) return if (state !is CallState.IncomingCall) return
_errorMessage.value = null scope.launch {
remoteDescriptionSet = false _errorMessage.value = null
pendingIceCandidates.clear() remoteDescriptionSet = false
// Don't clear pendingIceCandidates here — they were buffered while ringing
try { try {
createWebRtcSession() withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e) Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to accept call: ${e.message}" _errorMessage.value = "Failed to accept call: ${e.message}"
return return@launch
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return
} }
session.addAudioTrack() val session =
if (state.callType == CallType.VIDEO) { webRtcSession ?: run {
session.addVideoTrack() _errorMessage.value = "Failed to create WebRTC session"
_localVideoTrack.value = session.getLocalVideoTrack() return@launch
} }
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) session.addAudioTrack()
flushPendingIceCandidates() if (state.callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
}
session.createAnswer { sdp -> session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
scope.launch { flushPendingIceCandidates()
callManager.acceptCall(sdp.description)
session.createAnswer { sdp ->
scope.launch {
callManager.acceptCall(sdp.description)
}
} }
} }
} }
@@ -81,10 +81,10 @@ class CallForegroundService : Service() {
val channel = val channel =
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Calls", getString(R.string.call_ongoing),
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Ongoing call notification" description = getString(R.string.call_ongoing_description)
} }
val notificationManager = getSystemService(NotificationManager::class.java) val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
@@ -94,7 +94,7 @@ class CallForegroundService : Service() {
NotificationCompat NotificationCompat
.Builder(this, CHANNEL_ID) .Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(getString(R.string.app_name))
.setContentText("Call with $peerName") .setContentText(getString(R.string.call_with, peerName))
.setSmallIcon(R.drawable.amethyst) .setSmallIcon(R.drawable.amethyst)
.setOngoing(true) .setOngoing(true)
.build() .build()
@@ -519,10 +519,10 @@ object NotificationUtils {
callChannel = callChannel =
NotificationChannel( NotificationChannel(
CALL_CHANNEL_ID, CALL_CHANNEL_ID,
"Incoming calls", stringRes(applicationContext, R.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH, NotificationManager.IMPORTANCE_HIGH,
).apply { ).apply {
description = "Notifications for incoming voice and video calls" description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
} }
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
@@ -573,7 +573,7 @@ object NotificationUtils {
NotificationCompat NotificationCompat
.Builder(applicationContext, channel.id) .Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst) .setSmallIcon(R.drawable.amethyst)
.setContentTitle("Incoming call") .setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentText(callerName) .setContentText(callerName)
.setLargeIcon(callerBitmap) .setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent) .setContentIntent(contentPendingIntent)
@@ -584,8 +584,8 @@ object NotificationUtils {
.setOngoing(true) .setOngoing(true)
.setTimeoutAfter(60_000) .setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, "Reject", contentPendingIntent) .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent)
.addAction(R.drawable.amethyst, "Accept", fullScreenPendingIntent) .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.vitorpamplona.amethyst.R
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.service.call.CallController 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.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -98,6 +100,7 @@ fun CallScreen(
} }
KeepScreenOn() KeepScreenOn()
EnterPipOnLeave(callState)
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) { when (val state = callState) {
@@ -108,7 +111,7 @@ fun CallScreen(
is CallState.Offering -> { is CallState.Offering -> {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKey,
statusText = "Calling...", statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
) )
@@ -131,7 +134,7 @@ fun CallScreen(
is CallState.Connecting -> { is CallState.Connecting -> {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKey,
statusText = "Connecting...", statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
) )
@@ -152,7 +155,7 @@ fun CallScreen(
is CallState.Ended -> { is CallState.Ended -> {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKey,
statusText = "Call ended", statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { onCallEnded() }, onHangup = { onCallEnded() },
) )
@@ -164,7 +167,7 @@ fun CallScreen(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = { action = {
Text( Text(
"Dismiss", stringRes(R.string.call_dismiss),
modifier = modifier =
Modifier.padding(8.dp), Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary, color = MaterialTheme.colorScheme.inversePrimary,
@@ -225,7 +228,7 @@ private fun CallInProgressUI(
) { ) {
Icon( Icon(
Icons.Default.CallEnd, Icons.Default.CallEnd,
contentDescription = "Hang up", contentDescription = stringRes(R.string.call_hangup),
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) )
@@ -270,7 +273,14 @@ private fun IncomingCallUI(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( 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, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp, fontSize = 16.sp,
) )
@@ -286,7 +296,7 @@ private fun IncomingCallUI(
) { ) {
Icon( Icon(
Icons.Default.CallEnd, Icons.Default.CallEnd,
contentDescription = "Reject", contentDescription = stringRes(R.string.call_reject),
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) )
@@ -299,7 +309,7 @@ private fun IncomingCallUI(
) { ) {
Icon( Icon(
Icons.Default.Call, Icons.Default.Call,
contentDescription = "Accept", contentDescription = stringRes(R.string.call_accept),
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) )
@@ -428,7 +438,7 @@ private fun ConnectedCallUI(
) { ) {
Icon( Icon(
imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, 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, tint = if (isAudioMuted) Color.Red else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
@@ -439,7 +449,7 @@ private fun ConnectedCallUI(
) { ) {
Icon( Icon(
imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, 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, tint = if (!isVideoEnabled) Color.Red else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
@@ -450,7 +460,7 @@ private fun ConnectedCallUI(
) { ) {
Icon( Icon(
imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, 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, tint = if (isSpeakerOn) Color.Cyan else Color.White,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
) )
@@ -465,7 +475,7 @@ private fun ConnectedCallUI(
) { ) {
Icon( Icon(
Icons.Default.CallEnd, Icons.Default.CallEnd,
contentDescription = "Hang up", contentDescription = stringRes(R.string.call_hangup),
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp), 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( Icon(
imageVector = Icons.Default.Videocam, imageVector = Icons.Default.Videocam,
contentDescription = "Video call", contentDescription = stringRes(R.string.call_video),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
@@ -113,7 +113,7 @@ fun RenderRoomTopBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Call, imageVector = Icons.Default.Call,
contentDescription = "Voice call", contentDescription = stringRes(R.string.call_voice),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp), 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_your_turn">%1$s moved — your turn</string>
<string name="app_notification_chess_summary">Chess updates</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="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string> <string name="channel_list_join_conversation">Join Conversation</string>