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 @@ + + +