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