Merge pull request #2083 from vitorpamplona/claude/callscreen-separate-activity-JfFYu

Implement Picture-in-Picture mode for call activity
This commit is contained in:
Vitor Pamplona
2026-04-02 17:08:25 -04:00
committed by GitHub
14 changed files with 759 additions and 122 deletions
+17
View File
@@ -205,6 +205,19 @@
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
<activity
android:name=".ui.call.CallActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
android:supportsPictureInPicture="true"
android:launchMode="singleTop"
android:exported="false"
android:resizeableActivity="true"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:theme="@style/Theme.Amethyst"
/>
<activity
android:name=".service.playback.pip.PipVideoActivity"
android:autoRemoveFromRecents="true"
@@ -258,6 +271,10 @@
android:name=".service.notifications.NotificationReplyReceiver"
android:exported="false" />
<receiver
android:name=".ui.call.CallNotificationReceiver"
android:exported="false" />
</application>
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context
import android.content.Intent
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.model.LocalCache
@@ -82,11 +85,18 @@ class CallController(
// Remote video frame monitoring — detects when peer stops sending video
private val _isRemoteVideoActive = MutableStateFlow(false)
val isRemoteVideoActive: StateFlow<Boolean> = _isRemoteVideoActive.asStateFlow()
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null)
val remoteVideoAspectRatio: StateFlow<Float?> = _remoteVideoAspectRatio.asStateFlow()
private val lastRemoteFrameTimeMs = AtomicLong(0L)
private var remoteVideoMonitorJob: kotlinx.coroutines.Job? = null
private val remoteFrameSink =
VideoSink { _: VideoFrame ->
VideoSink { frame: VideoFrame ->
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
val w = frame.rotatedWidth
val h = frame.rotatedHeight
if (w > 0 && h > 0) {
_remoteVideoAspectRatio.value = w.toFloat() / h.toFloat()
}
}
// Audio/video toggle state (UI concerns, not domain state)
@@ -120,6 +130,7 @@ class CallController(
audioManager.stopRingbackTone()
audioManager.switchToCallAudioMode()
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
}
is CallState.Connected -> {
@@ -357,6 +368,7 @@ class CallController(
_isAudioMuted.value = false
_isVideoEnabled.value = false
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
videoPausedByProximity = false
webRtcSession?.dispose()
webRtcSession = null
@@ -453,14 +465,27 @@ class CallController(
}
}
private fun showIncomingCallNotification(callerPubKey: String) {
private suspend fun showIncomingCallNotification(callerPubKey: String) {
val callerUser = LocalCache.getUserIfExists(callerPubKey)
val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..."
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(context).data(pictureUrl).build()
val result = ImageLoader(context).execute(request)
(result.image?.asDrawable(context.resources) as? android.graphics.drawable.BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
NotificationUtils.sendCallNotification(
callerName = callerName,
callerBitmap = null,
callerBitmap = callerBitmap,
uri = uri,
applicationContext = context,
)
@@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -625,7 +629,7 @@ class EventNotificationConsumer(
}
}
private fun notifyIncomingCall(
private suspend fun notifyIncomingCall(
event: CallOfferEvent,
account: Account,
) {
@@ -636,10 +640,23 @@ class EventNotificationConsumer(
val callerUser = LocalCache.getUserIfExists(event.pubKey)
val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..."
val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val result = ImageLoader(applicationContext).execute(request)
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
NotificationUtils
.sendCallNotification(
callerName = callerName,
callerBitmap = null,
callerBitmap = callerBitmap,
uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}",
applicationContext = applicationContext,
)
@@ -523,6 +523,9 @@ object NotificationUtils {
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
// Silence the notification sound — CallAudioManager plays the ringtone
setSound(null, null)
enableVibration(false)
}
val notificationManager: NotificationManager =
@@ -544,8 +547,11 @@ object NotificationUtils {
val channel = getOrCreateCallChannel(applicationContext)
// Tapping the notification opens the CallActivity
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val contentPendingIntent =
PendingIntent.getActivity(
@@ -555,17 +561,32 @@ object NotificationUtils {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val fullScreenIntent =
Intent(applicationContext, MainActivity::class.java).apply {
data = uri.toUri()
// Accept launches CallActivity directly (not via BroadcastReceiver)
// to comply with Android 12+ notification trampoline restrictions.
val acceptIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(com.vitorpamplona.amethyst.ui.call.CallActivity.EXTRA_ACCEPT_CALL, true)
}
val fullScreenPendingIntent =
val acceptPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID + 1,
fullScreenIntent,
acceptIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val rejectIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply {
action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_REJECT_CALL
}
val rejectPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
CALL_NOTIFICATION_ID + 2,
rejectIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
@@ -577,15 +598,15 @@ object NotificationUtils {
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setFullScreenIntent(contentPendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Holds references to the active call state so that [CallActivity] can access
* the same [CallManager] and [CallController] owned by [AccountViewModel] in
* the main activity. Both activities run in the same process.
*/
object ActiveCallHolder {
var callManager: CallManager? = null
private set
var callController: CallController? = null
private set
var accountViewModel: AccountViewModel? = null
private set
fun set(
callManager: CallManager,
callController: CallController?,
accountViewModel: AccountViewModel,
) {
this.callManager = callManager
this.callController = callController
this.accountViewModel = accountViewModel
}
fun clear() {
callManager = null
callController = null
accountViewModel = null
}
}
@@ -0,0 +1,313 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.util.Rational
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class CallActivity : AppCompatActivity() {
val isInPipMode = mutableStateOf(false)
// Tracks whether we entered PiP at least once, so we can distinguish
// "user swiped PiP away" from "user pressed Home from full-screen call".
private var wasInPipMode = false
private val pipActionReceiver =
object : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_PIP_HANGUP -> {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
}
ACTION_PIP_TOGGLE_MUTE -> {
ActiveCallHolder.callController?.toggleAudioMute()
updatePipParams()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Show over lock screen and turn screen on for incoming calls
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
)
}
val callManager = ActiveCallHolder.callManager
val callController = ActiveCallHolder.callController
val accountViewModel = ActiveCallHolder.accountViewModel
if (callManager == null || accountViewModel == null) {
finish()
return
}
registerPipReceiver()
observeVideoStateForPip(callController)
handleAcceptCallIntent(intent)
setContent {
AmethystTheme {
CallScreen(
callManager = callManager,
callController = callController,
accountViewModel = accountViewModel,
onCallEnded = { finish() },
isInPipMode = isInPipMode.value,
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleAcceptCallIntent(intent)
}
private fun handleAcceptCallIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_ACCEPT_CALL, false) != true) return
// Clear the extra so it doesn't re-trigger on config changes
intent.removeExtra(EXTRA_ACCEPT_CALL)
com.vitorpamplona.amethyst.service.notifications.NotificationUtils
.cancelCallNotification(this)
val callController = ActiveCallHolder.callController ?: return
val callManager = ActiveCallHolder.callManager ?: return
val state = callManager.state.value
if (state is CallState.IncomingCall) {
callController.acceptIncomingCall(state.sdpOffer)
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterPipIfActive()
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
isInPipMode.value = isInPictureInPictureMode
if (isInPictureInPictureMode) {
wasInPipMode = true
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onStop() {
super.onStop()
// Only hang up if the user dismissed PiP (swiped it away).
// When PiP is dismissed, Android calls onStop with isInPictureInPictureMode == false
// after the activity was previously in PiP mode.
// We must NOT hang up when the user simply presses Home from the full-screen
// call UI (that enters PiP via onUserLeaveHint instead).
if (wasInPipMode && !isInPictureInPictureMode) {
val state = ActiveCallHolder.callManager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
}
finishAndRemoveTask()
}
}
override fun onDestroy() {
unregisterPipReceiver()
super.onDestroy()
}
private fun observeVideoStateForPip(controller: com.vitorpamplona.amethyst.service.call.CallController?) {
controller ?: return
lifecycleScope.launch {
controller.isRemoteVideoActive.collect {
updatePipParams()
}
}
}
private fun enterPipIfActive() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val callManager = ActiveCallHolder.callManager ?: return
val state = callManager.state.value
val isActive =
state is CallState.Connected ||
state is CallState.Connecting ||
state is CallState.Offering
if (isActive) {
try {
enterPictureInPictureMode(buildPipParams())
} catch (_: Exception) {
// PiP not supported or activity not in correct state
}
}
}
private fun updatePipParams() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (!isInPipMode.value) return
try {
setPictureInPictureParams(buildPipParams())
} catch (_: Exception) {
}
}
private fun buildPipParams(): PictureInPictureParams {
val aspectRatio = computePipAspectRatio()
val builder =
PictureInPictureParams
.Builder()
.setAspectRatio(aspectRatio)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setActions(buildPipActions())
}
return builder.build()
}
private fun computePipAspectRatio(): Rational {
val controller = ActiveCallHolder.callController ?: return Rational(9, 16)
val isVideoActive = controller.isRemoteVideoActive.value
val videoRatio = controller.remoteVideoAspectRatio.value
if (isVideoActive && videoRatio != null && videoRatio > 0f) {
// Clamp to Android's allowed PiP range (roughly 1:2.39 to 2.39:1)
val clamped = videoRatio.coerceIn(0.42f, 2.39f)
val num = (clamped * 1000).toInt()
return Rational(num, 1000)
}
// No active video — use portrait ratio
return Rational(9, 16)
}
private fun buildPipActions(): List<RemoteAction> {
val actions = mutableListOf<RemoteAction>()
// Mute / Unmute toggle
val isMuted = ActiveCallHolder.callController?.isAudioMuted?.value == true
val muteIntent =
PendingIntent.getBroadcast(
this,
PIP_MUTE_REQUEST_CODE,
Intent(ACTION_PIP_TOGGLE_MUTE).setPackage(packageName),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val muteAction =
RemoteAction(
Icon.createWithResource(
this,
if (isMuted) R.drawable.ic_mic_off else R.drawable.ic_mic_on,
),
getString(if (isMuted) R.string.call_unmute else R.string.call_mute),
getString(if (isMuted) R.string.call_unmute else R.string.call_mute),
muteIntent,
)
actions.add(muteAction)
// Hangup
val hangupIntent =
PendingIntent.getBroadcast(
this,
PIP_HANGUP_REQUEST_CODE,
Intent(ACTION_PIP_HANGUP).setPackage(packageName),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val hangupAction =
RemoteAction(
Icon.createWithResource(this, R.drawable.ic_call_end),
getString(R.string.call_hangup),
getString(R.string.call_hangup),
hangupIntent,
)
actions.add(hangupAction)
return actions
}
private fun registerPipReceiver() {
val filter =
IntentFilter().apply {
addAction(ACTION_PIP_HANGUP)
addAction(ACTION_PIP_TOGGLE_MUTE)
}
registerReceiver(pipActionReceiver, filter, RECEIVER_NOT_EXPORTED)
}
private fun unregisterPipReceiver() {
try {
unregisterReceiver(pipActionReceiver)
} catch (_: Exception) {
}
}
companion object {
const val EXTRA_ACCEPT_CALL = "com.vitorpamplona.amethyst.EXTRA_ACCEPT_CALL"
private const val ACTION_PIP_HANGUP = "com.vitorpamplona.amethyst.PIP_HANGUP"
private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE"
private const val PIP_HANGUP_REQUEST_CODE = 0x60001
private const val PIP_MUTE_REQUEST_CODE = 0x60002
fun launch(context: Context) {
context.startActivity(
Intent(context, CallActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/**
* Handles the Reject action from the incoming call notification.
*
* The Accept action launches [CallActivity] directly via PendingIntent.getActivity
* to comply with Android 12+ notification trampoline restrictions.
*/
class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
val callManager = ActiveCallHolder.callManager ?: return
GlobalScope.launch {
callManager.rejectCall()
}
}
}
}
companion object {
const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL"
}
}
@@ -94,6 +94,7 @@ fun CallScreen(
callController: CallController?,
accountViewModel: AccountViewModel,
onCallEnded: () -> Unit,
isInPipMode: Boolean = false,
) {
val callState by callManager.state.collectAsState()
val scope = rememberCoroutineScope()
@@ -106,7 +107,6 @@ fun CallScreen(
}
KeepScreenOn()
EnterPipOnLeave(callState)
Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) {
@@ -122,73 +122,97 @@ fun CallScreen(
}
is CallState.Offering -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel)
} else {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
}
is CallState.IncomingCall -> {
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission =
rememberCallWithPermission(context, isVideo = isVideoCall) {
callController?.acceptIncomingCall(state.sdpOffer)
}
IncomingCallUI(
callerPubKey = state.callerPubKey,
callType = state.callType,
accountViewModel = accountViewModel,
onAccept = acceptWithPermission,
onReject = { scope.launch { callManager.rejectCall() } },
)
if (isInPipMode) {
PipCallUI(callerPubKey = state.callerPubKey, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel)
} else {
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission =
rememberCallWithPermission(context, isVideo = isVideoCall) {
callController?.acceptIncomingCall(state.sdpOffer)
}
IncomingCallUI(
callerPubKey = state.callerPubKey,
callType = state.callType,
accountViewModel = accountViewModel,
onAccept = acceptWithPermission,
onReject = { scope.launch { callManager.rejectCall() } },
)
}
}
is CallState.Connecting -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel)
} else {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
}
is CallState.Connected -> {
ConnectedCallUI(
state = state,
callController = callController,
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() },
)
if (isInPipMode) {
PipConnectedCallUI(state = state, callController = callController, accountViewModel = accountViewModel)
} else {
ConnectedCallUI(
state = state,
callController = callController,
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() },
)
}
}
is CallState.Ended -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
)
if (!isInPipMode) {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
)
}
LaunchedEffect(Unit) {
delay(2000)
onCallEnded()
}
}
}
errorMessage?.let { error ->
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
Text(
stringRes(R.string.call_dismiss),
modifier =
Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
)
},
) {
Text(error)
if (!isInPipMode) {
errorMessage?.let { error ->
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
Text(
stringRes(R.string.call_dismiss),
modifier =
Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
)
},
) {
Text(error)
}
}
}
}
@@ -558,6 +582,120 @@ private fun formatDuration(seconds: Long): String {
return "%02d:%02d".format(mins, secs)
}
@Composable
private fun PipCallUI(
peerPubKey: String = "",
callerPubKey: String = "",
statusText: String,
accountViewModel: AccountViewModel,
) {
val pubKey = peerPubKey.ifEmpty { callerPubKey }
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 48.dp,
accountViewModel = accountViewModel,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
@Composable
private fun PipConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
) {
var elapsed by remember { mutableLongStateOf(0L) }
LaunchedEffect(state.startedAtEpoch) {
while (true) {
elapsed = TimeUtils.now() - state.startedAtEpoch
delay(1000)
}
}
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
// Remote video full screen in PiP
if (isRemoteVideoActive) {
remoteVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier = Modifier.fillMaxSize(),
mirror = false,
)
}
}
if (!isRemoteVideoActive) {
// Show small avatar + timer in PiP
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 48.dp,
accountViewModel = accountViewModel,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
)
}
} else {
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
}
}
}
@Composable
private fun KeepScreenOn() {
val context = LocalContext.current
@@ -569,36 +707,3 @@ 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) }
}
}
}
@@ -46,11 +46,11 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallScreen
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -167,20 +167,19 @@ fun AppNavigation(
DisplayCrashMessages(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
ObserveIncomingCalls(accountViewModel, nav)
ObserveIncomingCalls(accountViewModel)
}
@Composable
private fun ObserveIncomingCalls(
accountViewModel: AccountViewModel,
nav: INav,
) {
private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
val context = LocalContext.current
val callState by accountViewModel.callManager.state.collectAsState()
LaunchedEffect(callState) {
val state = callState
if (state is CallState.IncomingCall) {
nav.nav(Route.ActiveCall(state.callId, state.callerPubKey))
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallActivity.launch(context)
}
}
}
@@ -281,15 +280,6 @@ fun BuildNavigation(
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.ActiveCall> {
CallScreen(
callManager = accountViewModel.callManager,
callController = accountViewModel.callController,
accountViewModel = accountViewModel,
onCallEnded = { nav.popBack() },
)
}
composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
@@ -224,6 +224,11 @@ class AccountViewModel(
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
callController = controller
// Populate ActiveCallHolder so CallActivity can launch even when the app
// is in the background (e.g. full-screen incoming call intent).
com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
.set(callManager, controller, this)
}
val eventSync =
@@ -27,10 +27,11 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -51,16 +52,16 @@ fun ChatroomScreen(
val startVoiceCall =
rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE)
val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
CallActivity.launch(context)
}
val startVideoCall =
rememberCallWithPermission(context, isVideo = true) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO)
val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
CallActivity.launch(context)
}
DisappearingScaffold(
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01V11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
</vector>