From ede57065822c899fd0e796f54cb36e58db92d6a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 19:32:45 +0000 Subject: [PATCH 1/8] 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 --- amethyst/src/main/AndroidManifest.xml | 11 ++ .../amethyst/ui/call/ActiveCallHolder.kt | 55 +++++++++ .../amethyst/ui/call/CallActivity.kt | 104 ++++++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 34 ------ .../amethyst/ui/navigation/AppNavigation.kt | 24 ++-- .../chats/privateDM/ChatroomScreen.kt | 11 +- 6 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index f5e39a388..7bf548fea 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -205,6 +205,17 @@ tools:replace="screenOrientation" tools:ignore="DiscouragedApi" /> + + = 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) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 01398aa04..f7d4c9338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -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) } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index aafcb4a21..832f62808 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - composableFromEndArgs { - CallScreen( - callManager = accountViewModel.callManager, - callController = accountViewModel.callController, - accountViewModel = accountViewModel, - onCallEnded = { nav.popBack() }, - ) - } - composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 221519c7f..a833ae21e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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( From 4526a16ff9d9ec0596b4cd1266295a6bb413e156 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:04:16 +0000 Subject: [PATCH 2/8] fix: notification actions, PiP hangup, PiP compact UI, and PiP remote actions 1. Notification: silence channel sound (CallAudioManager handles ringtone), use CallNotificationReceiver for accept/reject actions instead of launching MainActivity 2. Cancel call notification when call is accepted (Connecting state) 3. PiP: hang up call when PiP is dismissed (activity destroyed) 4. PiP: add RemoteAction buttons (hangup, mute toggle) since Compose buttons are not interactive in PiP mode 5. PiP: show compact UI with smaller avatar (48dp) and smaller text when in picture-in-picture mode https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- amethyst/src/main/AndroidManifest.xml | 4 + .../amethyst/service/call/CallController.kt | 1 + .../notifications/NotificationUtils.kt | 41 ++- .../amethyst/ui/call/CallActivity.kt | 148 ++++++++++- .../ui/call/CallNotificationReceiver.kt | 63 +++++ .../amethyst/ui/call/CallScreen.kt | 243 ++++++++++++++---- .../src/main/res/drawable/ic_call_end.xml | 10 + amethyst/src/main/res/drawable/ic_mic_off.xml | 10 + amethyst/src/main/res/drawable/ic_mic_on.xml | 10 + 9 files changed, 458 insertions(+), 72 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt create mode 100644 amethyst/src/main/res/drawable/ic_call_end.xml create mode 100644 amethyst/src/main/res/drawable/ic_mic_off.xml create mode 100644 amethyst/src/main/res/drawable/ic_mic_on.xml diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 7bf548fea..2e748720d 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -269,6 +269,10 @@ android:name=".service.notifications.NotificationReplyReceiver" android:exported="false" /> + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 679b1cdcc..11205527d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -120,6 +120,7 @@ class CallController( audioManager.stopRingbackTone() audioManager.switchToCallAudioMode() audioManager.acquireProximityWakeLock() + NotificationUtils.cancelCallNotification(context) } is CallState.Connected -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index f59f44776..0fb7094ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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,29 @@ object NotificationUtils { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val fullScreenIntent = - Intent(applicationContext, MainActivity::class.java).apply { - data = uri.toUri() - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + val acceptIntent = + Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply { + action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_ACCEPT_CALL } - val fullScreenPendingIntent = - PendingIntent.getActivity( + val acceptPendingIntent = + PendingIntent.getBroadcast( 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 +595,16 @@ 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) + .setSilent(true) + .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()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 67374441e..c21c02182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -20,19 +20,51 @@ */ 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 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) + + 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) @@ -46,6 +78,8 @@ class CallActivity : AppCompatActivity() { return } + registerPipReceiver() + setContent { AmethystTheme { CallScreen( @@ -53,6 +87,7 @@ class CallActivity : AppCompatActivity() { callController = callController, accountViewModel = accountViewModel, onCallEnded = { finish() }, + isInPipMode = isInPipMode.value, ) } } @@ -65,12 +100,25 @@ class CallActivity : AppCompatActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - if (!isInPictureInPictureMode) { - // User expanded from PiP — bring the full call UI back + isInPipMode.value = isInPictureInPictureMode + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onDestroy() { + unregisterPipReceiver() + + // If the activity is being destroyed while still in an active call + // (e.g. user swiped PiP away), hang up the call. + val state = ActiveCallHolder.callManager?.state?.value + if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { + GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } } + + super.onDestroy() } 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 = @@ -78,21 +126,103 @@ class CallActivity : AppCompatActivity() { state is CallState.Connecting || state is CallState.Offering - if (isActive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isActive) { try { - val params = - PictureInPictureParams - .Builder() - .setAspectRatio(Rational(9, 16)) - .build() - enterPictureInPictureMode(params) + 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 builder = + PictureInPictureParams + .Builder() + .setAspectRatio(Rational(16, 9)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setActions(buildPipActions()) + } + + return builder.build() + } + + private fun buildPipActions(): List { + val actions = mutableListOf() + + // 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 { + 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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt new file mode 100644 index 000000000..1c4f62425 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt @@ -0,0 +1,63 @@ +/* + * 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 + +class CallNotificationReceiver : BroadcastReceiver() { + @OptIn(DelicateCoroutinesApi::class) + override fun onReceive( + context: Context, + intent: Intent, + ) { + when (intent.action) { + ACTION_ACCEPT_CALL -> { + val callController = ActiveCallHolder.callController + val callManager = ActiveCallHolder.callManager + val state = callManager?.state?.value + if (state is com.vitorpamplona.amethyst.commons.call.CallState.IncomingCall) { + callController?.acceptIncomingCall(state.sdpOffer) + } + NotificationUtils.cancelCallNotification(context) + CallActivity.launch(context) + } + + ACTION_REJECT_CALL -> { + val callManager = ActiveCallHolder.callManager + GlobalScope.launch { + callManager?.rejectCall() + } + NotificationUtils.cancelCallNotification(context) + } + } + } + + companion object { + const val ACTION_ACCEPT_CALL = "com.vitorpamplona.amethyst.ACCEPT_CALL" + const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index f7d4c9338..5e292786b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -94,6 +94,7 @@ fun CallScreen( callController: CallController?, accountViewModel: AccountViewModel, onCallEnded: () -> Unit, + isInPipMode: Boolean = false, ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() @@ -121,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) + } } } } @@ -557,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(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 diff --git a/amethyst/src/main/res/drawable/ic_call_end.xml b/amethyst/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 000000000..41493e768 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,10 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_mic_off.xml b/amethyst/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 000000000..4da7b3e99 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/amethyst/src/main/res/drawable/ic_mic_on.xml b/amethyst/src/main/res/drawable/ic_mic_on.xml new file mode 100644 index 000000000..e4f2d562f --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_mic_on.xml @@ -0,0 +1,10 @@ + + + From 47ce8584815f3ee424cb65ba752ef29127d9772f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:27:00 +0000 Subject: [PATCH 3/8] fix: PiP portrait aspect ratio and PiP close (X) button hangup - Restore Rational(9, 16) portrait aspect ratio for PiP window - Add onStop handler: when PiP is dismissed via X button, hang up the call and finish the activity (mirrors PipVideoActivity pattern) - onPictureInPictureModeChanged also hangs up if activity is finishing https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- .../amethyst/ui/call/CallActivity.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index c21c02182..03a4e78f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -98,9 +98,32 @@ class CallActivity : AppCompatActivity() { enterPipIfActive() } + @OptIn(DelicateCoroutinesApi::class) override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) isInPipMode.value = isInPictureInPictureMode + + if (!isInPictureInPictureMode) { + // User pressed the X button on PiP or expanded it. + // If the activity is finishing (user dismissed PiP), hang up. + if (isFinishing) { + GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onStop() { + super.onStop() + // When PiP is dismissed (X button), Android stops the activity. + // If we're not in PiP anymore and the activity is stopping, hang up and finish. + if (!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() + } } @OptIn(DelicateCoroutinesApi::class) @@ -148,7 +171,7 @@ class CallActivity : AppCompatActivity() { val builder = PictureInPictureParams .Builder() - .setAspectRatio(Rational(16, 9)) + .setAspectRatio(Rational(9, 16)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { builder.setActions(buildPipActions()) From 7024994b9a8ca53070e6e6c1dd87acef9740f56a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:30:36 +0000 Subject: [PATCH 4/8] fix: PiP aspect ratio matches remote video when available - Track remote video aspect ratio in CallController via VideoFrame rotatedWidth/rotatedHeight - PiP uses video's aspect ratio when remote video is active, falls back to 9:16 portrait for audio-only calls - Observe isRemoteVideoActive to update PiP params when video starts/stops during a call https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- .../amethyst/service/call/CallController.kt | 10 ++++++- .../amethyst/ui/call/CallActivity.kt | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 11205527d..488500434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -82,11 +82,18 @@ class CallController( // Remote video frame monitoring — detects when peer stops sending video private val _isRemoteVideoActive = MutableStateFlow(false) val isRemoteVideoActive: StateFlow = _isRemoteVideoActive.asStateFlow() + private val _remoteVideoAspectRatio = MutableStateFlow(null) + val remoteVideoAspectRatio: StateFlow = _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) @@ -358,6 +365,7 @@ class CallController( _isAudioMuted.value = false _isVideoEnabled.value = false _isRemoteVideoActive.value = false + _remoteVideoAspectRatio.value = null videoPausedByProximity = false webRtcSession?.dispose() webRtcSession = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 03a4e78f9..c94a32329 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -35,6 +35,7 @@ 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 @@ -79,6 +80,7 @@ class CallActivity : AppCompatActivity() { } registerPipReceiver() + observeVideoStateForPip(callController) setContent { AmethystTheme { @@ -140,6 +142,15 @@ class CallActivity : AppCompatActivity() { 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 @@ -168,10 +179,11 @@ class CallActivity : AppCompatActivity() { } private fun buildPipParams(): PictureInPictureParams { + val aspectRatio = computePipAspectRatio() val builder = PictureInPictureParams .Builder() - .setAspectRatio(Rational(9, 16)) + .setAspectRatio(aspectRatio) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { builder.setActions(buildPipActions()) @@ -180,6 +192,22 @@ class CallActivity : AppCompatActivity() { 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 { val actions = mutableListOf() From dbdd53c6c3f5146a44a5e8532ee2b1b8128a2450 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:34:07 +0000 Subject: [PATCH 5/8] fix: load caller name and profile picture in call notification Both CallController and EventNotificationConsumer now load the caller's profile picture via Coil before showing the notification, so the large icon displays the caller's avatar instead of being empty. The caller name was already loaded from LocalCache but now the bitmap accompanies it. https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- .../amethyst/service/call/CallController.kt | 20 ++++++++++++++++-- .../EventNotificationConsumer.kt | 21 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 488500434..bca75e0aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -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 @@ -462,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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index d47e1058c..356f65c40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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, ) From 724fd1040566dedb508615145b14812659e82fd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:47:56 +0000 Subject: [PATCH 6/8] feat: full-screen incoming call UI over lock screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable proper full-screen call experience when the phone is locked or the app is in the background: - CallActivity shows over lock screen (setShowWhenLocked/turnScreenOn) - Manifest adds showOnLockScreen and turnScreenOn attributes - Remove setSilent(true) from notification — it was blocking the full-screen intent from triggering (channel silence is enough) - Populate ActiveCallHolder eagerly in initCallController() so the full-screen intent can launch CallActivity even when the Compose UI is paused in the background https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- amethyst/src/main/AndroidManifest.xml | 2 ++ .../service/notifications/NotificationUtils.kt | 1 - .../vitorpamplona/amethyst/ui/call/CallActivity.kt | 12 ++++++++++++ .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 5 +++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 2e748720d..d03cf083a 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -213,6 +213,8 @@ android:launchMode="singleTop" android:exported="false" android:resizeableActivity="true" + android:showOnLockScreen="true" + android:turnScreenOn="true" android:theme="@style/Theme.Amethyst" /> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 0fb7094ea..3f92bd0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -602,7 +602,6 @@ object NotificationUtils { .setOngoing(true) .setTimeoutAfter(60_000) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setSilent(true) .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent) .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index c94a32329..8df8478cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -70,6 +70,18 @@ class CallActivity : AppCompatActivity() { 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 70818f512..07d432511 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 = From 80151965c7415925b82ed0598fbbcea3d1087b73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:55:00 +0000 Subject: [PATCH 7/8] fix: critical lifecycle bugs found during code review 1. onStop() no longer hangs up call when user switches apps - Added wasInPipMode flag to track if the activity was ever in PiP - onStop only hangs up + finishes when PiP was dismissed (swiped away), not when the user simply presses Home from the full-screen call UI - Pressing Home from full-screen enters PiP via onUserLeaveHint 2. Removed triple-hangup from overlapping lifecycle methods - onPictureInPictureModeChanged now only updates UI state - onStop handles PiP dismissal only - onDestroy only cleans up the PiP receiver (hangup already handled by CallController's state collector on Ended) 3. CallNotificationReceiver guards against stale notifications - Returns early if callManager/callController are null - Only launches CallActivity if the call is still IncomingCall - Cancels notification before any other action to prevent re-taps https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- .../amethyst/ui/call/CallActivity.kt | 31 +++++++------------ .../ui/call/CallNotificationReceiver.kt | 23 ++++++++------ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 8df8478cb..9ac561e48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -46,6 +46,10 @@ 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) @@ -112,26 +116,24 @@ class CallActivity : AppCompatActivity() { enterPipIfActive() } - @OptIn(DelicateCoroutinesApi::class) override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) isInPipMode.value = isInPictureInPictureMode - if (!isInPictureInPictureMode) { - // User pressed the X button on PiP or expanded it. - // If the activity is finishing (user dismissed PiP), hang up. - if (isFinishing) { - GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } - } + if (isInPictureInPictureMode) { + wasInPipMode = true } } @OptIn(DelicateCoroutinesApi::class) override fun onStop() { super.onStop() - // When PiP is dismissed (X button), Android stops the activity. - // If we're not in PiP anymore and the activity is stopping, hang up and finish. - if (!isInPictureInPictureMode) { + // 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() } @@ -140,17 +142,8 @@ class CallActivity : AppCompatActivity() { } } - @OptIn(DelicateCoroutinesApi::class) override fun onDestroy() { unregisterPipReceiver() - - // If the activity is being destroyed while still in an active call - // (e.g. user swiped PiP away), hang up the call. - val state = ActiveCallHolder.callManager?.state?.value - if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { - GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } - } - super.onDestroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt index 1c4f62425..ac6f7e1ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -36,22 +37,26 @@ class CallNotificationReceiver : BroadcastReceiver() { ) { when (intent.action) { ACTION_ACCEPT_CALL -> { + NotificationUtils.cancelCallNotification(context) + val callController = ActiveCallHolder.callController val callManager = ActiveCallHolder.callManager - val state = callManager?.state?.value - if (state is com.vitorpamplona.amethyst.commons.call.CallState.IncomingCall) { - callController?.acceptIncomingCall(state.sdpOffer) + if (callController == null || callManager == null) return + + val state = callManager.state.value + if (state is CallState.IncomingCall) { + callController.acceptIncomingCall(state.sdpOffer) + CallActivity.launch(context) } - NotificationUtils.cancelCallNotification(context) - CallActivity.launch(context) } ACTION_REJECT_CALL -> { - val callManager = ActiveCallHolder.callManager - GlobalScope.launch { - callManager?.rejectCall() - } NotificationUtils.cancelCallNotification(context) + + val callManager = ActiveCallHolder.callManager ?: return + GlobalScope.launch { + callManager.rejectCall() + } } } } From 989f10c6e2c9d861dd7a41fdce19e5dbed9b0f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 21:01:31 +0000 Subject: [PATCH 8/8] fix: notification Accept opens CallActivity directly (Android 12+) Android 12+ blocks starting activities from BroadcastReceivers used as notification trampolines. The Accept action now uses PendingIntent.getActivity to launch CallActivity directly with EXTRA_ACCEPT_CALL. CallActivity.onCreate/onNewIntent checks this extra and accepts the incoming call. The Reject action remains as a BroadcastReceiver since it doesn't need to start an activity. https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- .../notifications/NotificationUtils.kt | 9 +++++--- .../amethyst/ui/call/CallActivity.kt | 23 +++++++++++++++++++ .../ui/call/CallNotificationReceiver.kt | 22 +++++------------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 3f92bd0ca..a669d409b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -561,13 +561,16 @@ object NotificationUtils { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + // Accept launches CallActivity directly (not via BroadcastReceiver) + // to comply with Android 12+ notification trampoline restrictions. val acceptIntent = - Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply { - action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_ACCEPT_CALL + 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 acceptPendingIntent = - PendingIntent.getBroadcast( + PendingIntent.getActivity( applicationContext, CALL_NOTIFICATION_ID + 1, acceptIntent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 9ac561e48..071eb11e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -97,6 +97,7 @@ class CallActivity : AppCompatActivity() { registerPipReceiver() observeVideoStateForPip(callController) + handleAcceptCallIntent(intent) setContent { AmethystTheme { @@ -111,6 +112,27 @@ class CallActivity : AppCompatActivity() { } } + 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() @@ -274,6 +296,7 @@ class CallActivity : AppCompatActivity() { } 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt index ac6f7e1ca..d9f1594a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt @@ -23,12 +23,17 @@ package com.vitorpamplona.amethyst.ui.call import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.vitorpamplona.amethyst.commons.call.CallState 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( @@ -36,20 +41,6 @@ class CallNotificationReceiver : BroadcastReceiver() { intent: Intent, ) { when (intent.action) { - ACTION_ACCEPT_CALL -> { - NotificationUtils.cancelCallNotification(context) - - val callController = ActiveCallHolder.callController - val callManager = ActiveCallHolder.callManager - if (callController == null || callManager == null) return - - val state = callManager.state.value - if (state is CallState.IncomingCall) { - callController.acceptIncomingCall(state.sdpOffer) - CallActivity.launch(context) - } - } - ACTION_REJECT_CALL -> { NotificationUtils.cancelCallNotification(context) @@ -62,7 +53,6 @@ class CallNotificationReceiver : BroadcastReceiver() { } companion object { - const val ACTION_ACCEPT_CALL = "com.vitorpamplona.amethyst.ACCEPT_CALL" const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL" } }