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: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
|
||||
android:name=".service.playback.pip.PipVideoActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
@@ -258,6 +271,10 @@
|
||||
android:name=".service.notifications.NotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ui.call.CallNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -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<Boolean> = _isRemoteVideoActive.asStateFlow()
|
||||
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null)
|
||||
val remoteVideoAspectRatio: StateFlow<Float?> = _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,
|
||||
)
|
||||
|
||||
+19
-2
@@ -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,
|
||||
)
|
||||
|
||||
+30
-9
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
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<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
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.call.CallScreen
|
||||
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -167,20 +167,19 @@ fun AppNavigation(
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
|
||||
ObserveIncomingCalls(accountViewModel, nav)
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveIncomingCalls(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
val callState by accountViewModel.callManager.state.collectAsState()
|
||||
|
||||
LaunchedEffect(callState) {
|
||||
val state = callState
|
||||
if (state is CallState.IncomingCall) {
|
||||
nav.nav(Route.ActiveCall(state.callId, state.callerPubKey))
|
||||
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
|
||||
CallActivity.launch(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,15 +280,6 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.ActiveCall> {
|
||||
CallScreen(
|
||||
callManager = accountViewModel.callManager,
|
||||
callController = accountViewModel.callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onCallEnded = { nav.popBack() },
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.PublicChatChannel> {
|
||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+5
@@ -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 =
|
||||
|
||||
+6
-5
@@ -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(
|
||||
|
||||
@@ -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