diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index f5e39a388..d03cf083a 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -205,6 +205,19 @@
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
+
+
+
+
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..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
@@ -82,11 +85,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)
@@ -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,
)
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,
)
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 15e34967e..45839e99a 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,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())
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt
new file mode 100644
index 000000000..9ea9ccfc8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt
@@ -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
+ }
+}
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
new file mode 100644
index 000000000..071eb11e4
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt
@@ -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 {
+ 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 {
+ 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)
+ },
+ )
+ }
+ }
+}
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..d9f1594a6
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt
@@ -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"
+ }
+}
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..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()
@@ -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(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) }
- }
- }
-}
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/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 =
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(
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 @@
+
+
+