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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+5
-5
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user