feat: move CallScreen to its own activity for independent PiP

Separate the call UI into a dedicated CallActivity so it can enter
Picture-in-Picture mode independently of the main activity, allowing
users to continue browsing the app during an active call.

- Add CallActivity with PiP support via onUserLeaveHint
- Add ActiveCallHolder singleton to share call state between activities
- Launch CallActivity from call buttons and incoming call observer
- Remove in-app nav route for ActiveCall (now a separate activity)
- Remove EnterPipOnLeave composable (activity handles PiP directly)

https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ
This commit is contained in:
Claude
2026-04-02 19:32:45 +00:00
parent 72d4c5107c
commit ede5706582
6 changed files with 183 additions and 56 deletions
+11
View File
@@ -205,6 +205,17 @@
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:theme="@style/Theme.Amethyst"
/>
<activity
android:name=".service.playback.pip.PipVideoActivity"
android:autoRemoveFromRecents="true"
@@ -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,104 @@
/*
* 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.PictureInPictureParams
import android.content.Context
import android.content.Intent
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 com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class CallActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val callManager = ActiveCallHolder.callManager
val callController = ActiveCallHolder.callController
val accountViewModel = ActiveCallHolder.accountViewModel
if (callManager == null || accountViewModel == null) {
finish()
return
}
setContent {
AmethystTheme {
CallScreen(
callManager = callManager,
callController = callController,
accountViewModel = accountViewModel,
onCallEnded = { finish() },
)
}
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterPipIfActive()
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (!isInPictureInPictureMode) {
// User expanded from PiP — bring the full call UI back
}
}
private fun enterPipIfActive() {
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 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val params =
PictureInPictureParams
.Builder()
.setAspectRatio(Rational(9, 16))
.build()
enterPictureInPictureMode(params)
} catch (_: Exception) {
// PiP not supported or activity not in correct state
}
}
}
companion object {
fun launch(context: Context) {
context.startActivity(
Intent(context, CallActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
}
@@ -106,7 +106,6 @@ fun CallScreen(
}
KeepScreenOn()
EnterPipOnLeave(callState)
Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) {
@@ -569,36 +568,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)
}
@@ -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(