android: ability to hide active call (#3770)

* android: ability to hide active call

* enhancements

* fixed some problems and adapted to lock screen usage

* change

* reduce diff

* dealing with disable PiP by user

* fix back action

* fix hidden information on view rotation while info collapsed

* better info showing

* status bar color and user icon

* reorder

* experiment

* icon placement

* enhancements

* back button

* invitation accepted state handling

* awesome background work

* better service interaction and UI

* disabled call overlay when call ends and ability to accept a new call from the same contact while previous call is not ended

* incomming call alert

* enhancements

* text

* text2

* top area

* faster ending call

* a lot of enhancements

* paddings

* icon position

* move icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko 2024-02-06 04:44:02 +07:00 committed by GitHub
parent 09bbaa1c94
commit 5da8aef794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 933 additions and 189 deletions

View file

@ -103,11 +103,14 @@
</intent-filter> </intent-filter>
</activity-alias> </activity-alias>
<activity android:name=".views.call.CallActivity"
<activity android:name=".views.call.IncomingCallActivity"
android:showOnLockScreen="true" android:showOnLockScreen="true"
android:exported="false" android:exported="false"
android:launchMode="singleTask"/> android:launchMode="singleInstance"
android:supportsPictureInPicture="true"
android:autoRemoveFromRecents="true"
android:screenOrientation="portrait"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@ -133,6 +136,18 @@
android:stopWithTask="false"></service> android:stopWithTask="false"></service>
<!-- SimplexService restart on reboot --> <!-- SimplexService restart on reboot -->
<service
android:name=".CallService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"/>
<receiver
android:name=".CallService$CallActionReceiver"
android:enabled="true"
android:exported="false" />
<receiver <receiver
android:name=".SimplexService$StartReceiver" android:name=".SimplexService$StartReceiver"
android:enabled="true" android:enabled="true"

View file

@ -0,0 +1,176 @@
package chat.simplex.app
import android.app.*
import android.content.*
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.*
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import chat.simplex.app.model.NtfManager.EndCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.model.NotificationPreviewMode
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallState
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Instant
class CallService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
when (action) {
Action.START.name -> startService()
else -> Log.e(TAG, "No action in the intent")
}
} else {
Log.d(TAG, "null intent. Probably restarted by the system.")
}
startForeground(CALL_SERVICE_ID, serviceNotification)
return START_STICKY
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Call service created")
notificationManager = createNotificationChannel()
updateNotification()
startForeground(CALL_SERVICE_ID, serviceNotification)
}
override fun onDestroy() {
Log.d(TAG, "Call service destroyed")
try {
wakeLock?.let {
while (it.isHeld) it.release() // release all, in case acquired more than once
}
wakeLock = null
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
super.onDestroy()
}
private fun startService() {
Log.d(TAG, "CallService startService")
if (wakeLock != null) return
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
}
fun updateNotification() {
val call = chatModel.activeCall.value
val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
generalGetString(MR.strings.notification_preview_somebody)
else
call?.contact?.profile?.displayName ?: ""
val text = generalGetString(if (call?.supportsVideo() == true) MR.strings.call_service_notification_video_call else MR.strings.call_service_notification_audio_call)
val image = call?.contact?.image
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
BitmapFactory.decodeResource(resources, R.drawable.icon)
else
base64ToBitmap(image).asAndroidBitmap()
serviceNotification = createNotification(title, text, largeIcon, call?.connectedAt)
startForeground(CALL_SERVICE_ID, serviceNotification)
}
private fun createNotificationChannel(): NotificationManager? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(CALL_NOTIFICATION_CHANNEL_ID, CALL_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
return notificationManager
}
return null
}
private fun createNotification(title: String, text: String, icon: Bitmap, connectedAt: Instant? = null): Notification {
val pendingIntent: PendingIntent = Intent(this, CallActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
val endCallPendingIntent: PendingIntent = Intent(this, CallActionReceiver::class.java).let { notificationIntent ->
notificationIntent.setAction(EndCallAction)
PendingIntent.getBroadcast(this, 1, notificationIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(this, CALL_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_icon)
.setLargeIcon(icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSilent(true)
.addAction(R.drawable.ntf_icon, generalGetString(MR.strings.call_service_notification_end_call), endCallPendingIntent)
if (connectedAt != null) {
builder.setUsesChronometer(true)
builder.setWhen(connectedAt.epochSeconds * 1000)
}
return builder.build()
}
override fun onBind(intent: Intent): IBinder {
return CallServiceBinder()
}
inner class CallServiceBinder : Binder() {
fun getService() = this@CallService
}
enum class Action {
START,
}
class CallActionReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
EndCallAction -> {
val call = chatModel.activeCall.value
if (call != null) {
withBGApi {
chatModel.callManager.endCall(call)
}
}
}
else -> {
Log.e(TAG, "Unknown action. Make sure you provided an action")
}
}
}
}
companion object {
const val TAG = "CALL_SERVICE"
const val CALL_NOTIFICATION_CHANNEL_ID = "chat.simplex.app.CALL_SERVICE_NOTIFICATION"
const val CALL_NOTIFICATION_CHANNEL_NAME = "SimpleX Chat call service"
const val CALL_SERVICE_ID = 6788
const val WAKE_LOCK_TAG = "CallService::lock"
fun startService(): Intent {
Log.d(TAG, "CallService start")
return Intent(androidAppContext, CallService::class.java).also {
it.action = Action.START.name
ContextCompat.startForegroundService(androidAppContext, it)
}
}
fun stopService() {
androidAppContext.stopService(Intent(androidAppContext, CallService::class.java))
}
}
}

View file

@ -1,14 +1,15 @@
package chat.simplex.app package chat.simplex.app
import android.app.Application import android.app.*
import android.content.Context import android.content.Context
import androidx.compose.ui.platform.ClipboardManager
import chat.simplex.common.platform.Log import chat.simplex.common.platform.Log
import android.app.UiModeManager import android.content.Intent
import android.os.* import android.os.*
import androidx.lifecycle.* import androidx.lifecycle.*
import androidx.work.* import androidx.work.*
import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.* import chat.simplex.common.model.*
@ -18,6 +19,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme import chat.simplex.common.ui.theme.DefaultTheme
import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.call.activeCallDestroyWebView
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix import com.jakewharton.processphoenix.ProcessPhoenix
@ -184,6 +186,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
SimplexService.safeStopService() SimplexService.safeStopService()
} }
override fun androidCallServiceSafeStop() {
CallService.stopService()
}
override fun androidNotificationsModeChanged(mode: NotificationsMode) { override fun androidNotificationsModeChanged(mode: NotificationsMode) {
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) { if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
appPrefs.backgroundServiceNoticeShown.set(false) appPrefs.backgroundServiceNoticeShown.set(false)
@ -254,6 +260,28 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode) uiModeManager.setApplicationNightMode(mode)
} }
override fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long?, chatId: ChatId?) {
val context = mainActivity.get() ?: return
val intent = Intent(context, CallActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
if (acceptCall) {
intent.setAction(AcceptCallAction)
.putExtra("remoteHostId", remoteHostId)
.putExtra("chatId", chatId)
}
intent.flags += Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
context.startActivity(intent)
}
override fun androidPictureInPictureAllowed(): Boolean {
val appOps = androidAppContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
return appOps.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
}
override fun androidCallEnded() {
activeCallDestroyWebView()
}
override suspend fun androidAskToAllowBackgroundCalls(): Boolean { override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
if (SimplexService.isBackgroundRestricted()) { if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred() val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()

View file

@ -34,12 +34,13 @@ import kotlin.system.exitProcess
class SimplexService: Service() { class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isStartingService = false private var isCheckingNewMessages = false
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null private var serviceNotification: Notification? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId") Log.d(TAG, "onStartCommand startId: $startId")
isServiceStarting = false
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
Log.d(TAG, "intent action $action") Log.d(TAG, "intent action $action")
@ -71,6 +72,7 @@ class SimplexService: Service() {
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
} else { } else {
isServiceStarting = false
isServiceStarted = true isServiceStarted = true
// In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here // In case of self-destruct is enabled the initialization process will not start in SimplexApp, Let's start it here
if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) { if (DatabaseUtils.ksSelfDestructPassword.get() != null && chatModel.chatDbStatus.value == null) {
@ -89,6 +91,7 @@ class SimplexService: Service() {
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}") Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
} }
isServiceStarting = false
isServiceStarted = false isServiceStarted = false
stopAfterStart = false stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED) saveServiceState(this, ServiceState.STOPPED)
@ -101,9 +104,9 @@ class SimplexService: Service() {
private fun startService() { private fun startService() {
Log.d(TAG, "SimplexService startService") Log.d(TAG, "SimplexService startService")
if (wakeLock != null || isStartingService) return if (wakeLock != null || isCheckingNewMessages) return
val self = this val self = this
isStartingService = true isCheckingNewMessages = true
withLongRunningApi { withLongRunningApi {
val chatController = ChatController val chatController = ChatController
waitDbMigrationEnds(chatController) waitDbMigrationEnds(chatController)
@ -123,7 +126,7 @@ class SimplexService: Service() {
} }
} }
} finally { } finally {
isStartingService = false isCheckingNewMessages = false
} }
} }
} }
@ -262,6 +265,7 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE" private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce" private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
var isServiceStarting = false
var isServiceStarted = false var isServiceStarted = false
private var stopAfterStart = false private var stopAfterStart = false
@ -281,7 +285,7 @@ class SimplexService: Service() {
fun safeStopService() { fun safeStopService() {
if (isServiceStarted) { if (isServiceStarted) {
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java)) androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
} else { } else if (isServiceStarting) {
stopAfterStart = true stopAfterStart = true
} }
} }
@ -291,6 +295,7 @@ class SimplexService: Service() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also { Intent(androidAppContext, SimplexService::class.java).also {
it.action = action.name it.action = action.name
isServiceStarting = true
ContextCompat.startForegroundService(androidAppContext, it) ContextCompat.startForegroundService(androidAppContext, it)
} }
} }

View file

@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.* import androidx.core.app.*
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.TAG import chat.simplex.app.TAG
import chat.simplex.app.views.call.IncomingCallActivity import chat.simplex.app.views.call.CallActivity
import chat.simplex.app.views.call.getKeyguardManager import chat.simplex.app.views.call.getKeyguardManager
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
@ -33,6 +33,7 @@ object NtfManager {
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2" const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL" const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL" const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
const val EndCallAction: String = "chat.simplex.app.END_CALL"
const val CallNotificationId: Int = -1 const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId" private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId" private const val ChatIdKey: String = "chatId"
@ -157,7 +158,7 @@ object NtfManager {
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON } val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder = var ntfBuilder =
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) { if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java) val fullScreenIntent = Intent(context, CallActivity::class.java)
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
NotificationCompat.Builder(context, CallChannel) NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true) .setFullScreenIntent(fullScreenPendingIntent, true)

View file

@ -1,17 +1,18 @@
package chat.simplex.app.views.call package chat.simplex.app.views.call
import android.app.Activity import android.app.*
import android.app.KeyguardManager import android.content.*
import android.content.Context import android.content.res.Configuration
import android.content.Intent import android.graphics.Rect
import android.os.Build import android.os.*
import android.os.Bundle import android.util.Rational
import chat.simplex.common.platform.Log import android.view.*
import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.trackPipAnimationHintView
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
@ -22,33 +23,115 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.app.model.NtfManager.OpenChatAction import chat.simplex.common.platform.*
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.* import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import java.lang.ref.WeakReference
import chat.simplex.common.platform.chatModel as m
class IncomingCallActivity: ComponentActivity() { class CallActivity: ComponentActivity(), ServiceConnection {
var boundService: CallService? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { IncomingCallActivityView(ChatModel) } callActivity = WeakReference(this)
unlockForIncomingCall() when (intent?.action) {
AcceptCallAction -> {
val remoteHostId = intent.getLongExtra("remoteHostId", -1).takeIf { it != -1L }
val chatId = intent.getStringExtra("chatId")
val invitation = (m.callInvitations.values + m.activeCallInvitation.value).lastOrNull {
it?.remoteHostId == remoteHostId && it?.contact?.id == chatId
}
if (invitation != null) {
m.callManager.acceptIncomingCall(invitation = invitation)
}
}
}
setContent { CallActivityView() }
if (isOnLockScreenNow()) {
unlockForIncomingCall()
}
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
lockAfterIncomingCall() if (isOnLockScreenNow()) {
lockAfterIncomingCall()
}
try {
unbindService(this)
} catch (e: Exception) {
Log.i(TAG, "Unable to unbind service: " + e.stackTraceToString())
}
}
private fun isOnLockScreenNow() = getKeyguardManager(this).isKeyguardLocked
fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) {
// By manually specifying source rect we exclude empty background while toggling PiP
val builder = PictureInPictureParams.Builder()
.setAspectRatio(viewRatio)
.setSourceRectHint(sourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(video)
}
setPictureInPictureParams(builder.build())
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
m.activeCallViewIsCollapsed.value = isInPictureInPictureMode
val layoutType = if (!isInPictureInPictureMode) {
LayoutType.Default
} else {
LayoutType.RemoteVideo
}
m.callCommand.add(WCallCommand.Layout(layoutType))
}
override fun onBackPressed() {
if (isOnLockScreenNow()) {
super.onBackPressed()
} else {
m.activeCallViewIsCollapsed.value = true
}
}
override fun onPictureInPictureRequested(): Boolean {
Log.d(TAG, "Requested picture-in-picture from the system")
return super.onPictureInPictureRequested()
}
override fun onUserLeaveHint() {
// On Android 12+ PiP is enabled automatically when a user hides the app
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
enterPictureInPictureMode()
}
}
override fun onResume() {
super.onResume()
m.activeCallViewIsCollapsed.value = false
} }
private fun unlockForIncomingCall() { private fun unlockForIncomingCall() {
@ -72,6 +155,23 @@ class IncomingCallActivity: ComponentActivity() {
} }
} }
fun startServiceAndBind() {
/**
* On Android 12 there is a bug that prevents starting activity after pressing back button
* (the error says that it denies to start activity in background).
* Workaround is to bind to a service
* */
bindService(CallService.startService(), this, 0)
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
boundService = (service as CallService.CallServiceBinder).getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
boundService = null
}
companion object { companion object {
const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON const val activityFlags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON
} }
@ -80,38 +180,96 @@ class IncomingCallActivity: ComponentActivity() {
fun getKeyguardManager(context: Context): KeyguardManager = fun getKeyguardManager(context: Context): KeyguardManager =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
@Composable @Composable
fun IncomingCallActivityView(m: ChatModel) { fun CallActivityView() {
val switchingCall = m.switchingCall.value val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value val invitation = m.activeCallInvitation.value
val call = m.activeCall.value val call = remember { m.activeCall }.value
val showCallView = m.showCallView.value val showCallView = m.showCallView.value
val activity = LocalContext.current as Activity val activity = LocalContext.current as CallActivity
LaunchedEffect(invitation, call, switchingCall, showCallView) { LaunchedEffect(Unit) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) { snapshotFlow { m.activeCallViewIsCollapsed.value }
Log.d(TAG, "IncomingCallActivityView: finishing activity") .collect { collapsed ->
activity.finish() when {
} collapsed -> {
if (!platform.androidPictureInPictureAllowed() || !callSupportsVideo()) {
activity.moveTaskToBack(true)
activity.startActivity(Intent(activity, MainActivity::class.java))
} else if (!activity.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.RESUMED) {
// User pressed back button, show MainActivity
activity.startActivity(Intent(activity, MainActivity::class.java))
activity.enterPictureInPictureMode()
}
}
callSupportsVideo() && !platform.androidPictureInPictureAllowed() -> {
// PiP disabled by user
platform.androidStartCallActivity(false)
}
activity.isInPictureInPictureMode -> {
platform.androidStartCallActivity(false)
}
}
}
} }
SimpleXTheme { SimpleXTheme {
Surface( var prevCall by remember { mutableStateOf(call) }
Modifier KeyChangeEffect(m.activeCall.value) {
.fillMaxSize(), if (m.activeCall.value != null) {
color = MaterialTheme.colors.background, prevCall = m.activeCall.value
contentColor = LocalContentColor.current activity.boundService?.updateNotification()
) { }
if (showCallView) { }
Box { Box(Modifier.background(Color.Black)) {
ActiveCallView() if (call != null) {
if (invitation != null) IncomingCallAlertView(invitation, m) val view = LocalView.current
ActiveCallView()
if (callSupportsVideo()) {
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
activity.setPipParams(callSupportsVideo(), viewRatio = Rational(view.width, view.height))
activity.trackPipAnimationHintView(view)
}
}
}
} else if (prevCall != null) {
prevCall?.let { ActiveCallOverlayDisabled(it) }
}
if (invitation != null) {
if (call == null) {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
) {
IncomingCallLockScreenAlert(invitation, m)
}
} else {
IncomingCallAlertView(invitation, m)
} }
} else if (invitation != null) {
IncomingCallLockScreenAlert(invitation, m)
} }
} }
} }
LaunchedEffect(call == null) {
if (call != null) {
activity.startServiceAndBind()
}
}
LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "CallActivityView: finishing activity")
activity.finish()
}
}
} }
/**
* Related to lockscreen
* */
@Composable @Composable
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) { fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager val cm = chatModel.callManager
@ -135,7 +293,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = { openApp = {
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
.setAction(OpenChatAction) .setAction(NtfManager.OpenChatAction)
.putExtra("userId", invitation.user.userId) .putExtra("userId", invitation.user.userId)
.putExtra("chatId", invitation.contact.id) .putExtra("chatId", invitation.contact.id)
context.startActivity(intent) context.startActivity(intent)

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.LocalServerSocket import android.net.LocalServerSocket
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import chat.simplex.common.* import chat.simplex.common.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
@ -25,7 +26,8 @@ val defaultLocale: Locale = Locale.getDefault()
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
lateinit var androidAppContext: Context lateinit var androidAppContext: Context
lateinit var mainActivity: WeakReference<FragmentActivity> var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
fun initHaskell() { fun initHaskell() {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000) val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)

View file

@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -50,20 +51,30 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
// Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak
@SuppressLint("StaticFieldLeak")
private var staticWebView: WebView? = null
// WebView methods must be called on Main thread
fun activeCallDestroyWebView() = withApi {
// Stop it when call ended
platform.androidCallServiceSafeStop()
staticWebView?.destroy()
staticWebView = null
Log.d(TAG, "CallView: webview was destroyed")
}
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
@Composable @Composable
actual fun ActiveCallView() { actual fun ActiveCallView() {
val chatModel = ChatModel
BackHandler(onBack = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
})
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } val proximityLock = remember {
LaunchedEffect(Unit) { val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
// Start service when call happening since it's not already started. if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
if (!ntfModeService) platform.androidServiceStart() } else {
null
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@ -93,22 +104,24 @@ actual fun ActiveCallView() {
} }
} }
am.registerAudioDeviceCallback(audioCallback, null) am.registerAudioDeviceCallback(audioCallback, null)
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
} else {
null
}
proximityLock?.acquire()
onDispose { onDispose {
// Stop it when call ended
if (!ntfModeService) platform.androidServiceSafeStop()
dropAudioManagerOverrides() dropAudioManagerOverrides()
am.unregisterAudioDeviceCallback(audioCallback) am.unregisterAudioDeviceCallback(audioCallback)
proximityLock?.release() if (proximityLock?.isHeld == true) {
proximityLock.release()
}
}
}
LaunchedEffect(chatModel.activeCallViewIsCollapsed.value) {
if (chatModel.activeCallViewIsCollapsed.value) {
if (proximityLock?.isHeld == true) proximityLock.release()
} else {
delay(1000)
if (proximityLock?.isHeld == false) proximityLock.acquire()
} }
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val call = chatModel.activeCall.value
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg -> WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg") Log.d(TAG, "received from WebRTCView: $apiMsg")
@ -156,7 +169,6 @@ actual fun ActiveCallView() {
is WCallResponse.Ended -> { is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended) chatModel.activeCall.value = call.copy(callState = CallState.Ended)
withBGApi { chatModel.callManager.endCall(call) } withBGApi { chatModel.callManager.endCall(call) }
chatModel.showCallView.value = false
} }
is WCallResponse.Ok -> when (val cmd = apiMsg.command) { is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Answer -> is WCallCommand.Answer ->
@ -173,8 +185,9 @@ actual fun ActiveCallView() {
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
} }
} }
is WCallCommand.End -> is WCallCommand.End -> {
chatModel.showCallView.value = false withBGApi { chatModel.callManager.endCall(call) }
}
else -> {} else -> {}
} }
is WCallResponse.Error -> { is WCallResponse.Error -> {
@ -183,8 +196,16 @@ actual fun ActiveCallView() {
} }
} }
} }
val call = chatModel.activeCall.value val showOverlay = when {
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth) call == null -> false
!platform.androidPictureInPictureAllowed() -> true
!call.supportsVideo() -> true
!chatModel.activeCallViewIsCollapsed.value -> true
else -> false
}
if (call != null && showOverlay) {
ActiveCallOverlay(call, chatModel, audioViaBluetooth)
}
} }
val context = LocalContext.current val context = LocalContext.current
@ -229,6 +250,20 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
) )
} }
@Composable
fun ActiveCallOverlayDisabled(call: Call) {
ActiveCallOverlayLayout(
call = call,
speakerCanBeEnabled = false,
enabled = false,
dismiss = {},
toggleAudio = {},
toggleVideo = {},
toggleSound = {},
flipCamera = {}
)
}
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) { private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker") Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
@ -271,59 +306,69 @@ private fun dropAudioManagerOverrides() {
private fun ActiveCallOverlayLayout( private fun ActiveCallOverlayLayout(
call: Call, call: Call,
speakerCanBeEnabled: Boolean, speakerCanBeEnabled: Boolean,
enabled: Boolean = true,
dismiss: () -> Unit, dismiss: () -> Unit,
toggleAudio: () -> Unit, toggleAudio: () -> Unit,
toggleVideo: () -> Unit, toggleVideo: () -> Unit,
toggleSound: () -> Unit, toggleSound: () -> Unit,
flipCamera: () -> Unit flipCamera: () -> Unit
) { ) {
Column(Modifier.padding(DEFAULT_PADDING)) { Column {
when (call.peerMedia ?: call.localMedia) { val media = call.peerMedia ?: call.localMedia
CallMediaType.Video -> { CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
CallInfoView(call, alignment = Alignment.Start) if (media == CallMediaType.Video) {
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) { Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
DisabledBackgroundCallsButton()
}
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
ToggleAudioButton(call, toggleAudio)
Spacer(Modifier.size(40.dp))
IconButton(onClick = dismiss) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
}
if (call.videoEnabled) {
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, flipCamera)
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, toggleVideo)
}
}
} }
CallMediaType.Audio -> { }
Spacer(Modifier.fillMaxHeight().weight(1f)) Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
Column( when (media) {
Modifier.fillMaxWidth(), CallMediaType.Video -> {
horizontalAlignment = Alignment.CenterHorizontally, VideoCallInfoView(call)
verticalArrangement = Arrangement.Center Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
) { DisabledBackgroundCallsButton()
ProfileImage(size = 192.dp, image = call.contact.profile.image) }
CallInfoView(call, alignment = Alignment.CenterHorizontally) Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
} ToggleAudioButton(call, enabled, toggleAudio)
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) { Spacer(Modifier.size(40.dp))
DisabledBackgroundCallsButton() IconButton(onClick = dismiss, enabled = enabled) {
} Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) { }
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { if (call.videoEnabled) {
IconButton(onClick = dismiss) { ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp)) ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo)
} else {
Spacer(Modifier.size(48.dp))
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo)
} }
} }
Box(Modifier.padding(start = 32.dp)) { }
ToggleAudioButton(call, toggleAudio)
CallMediaType.Audio -> {
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
AudioCallInfoView(call)
} }
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
Box(Modifier.padding(end = 32.dp)) { DisabledBackgroundCallsButton()
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound) }
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
IconButton(onClick = dismiss, enabled = enabled) {
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
}
}
Box(Modifier.padding(start = 32.dp)) {
ToggleAudioButton(call, enabled, toggleAudio)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.padding(end = 32.dp)) {
ToggleSoundButton(call, speakerCanBeEnabled && enabled, toggleSound)
}
} }
} }
} }
@ -333,7 +378,7 @@ private fun ActiveCallOverlayLayout(
} }
@Composable @Composable
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, action: () -> Unit, enabled: Boolean = true) { private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit) {
if (call.hasMedia) { if (call.hasMedia) {
IconButton(onClick = action, enabled = enabled) { IconButton(onClick = action, enabled = enabled) {
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp)) Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
@ -344,28 +389,26 @@ private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, a
} }
@Composable @Composable
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) { private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
if (call.audioEnabled) { if (call.audioEnabled) {
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, toggleAudio) ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio)
} else { } else {
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, toggleAudio) ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio)
} }
} }
@Composable @Composable
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) { private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
if (call.soundSpeaker) { if (call.soundSpeaker) {
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, toggleSound, enabled) ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
} else { } else {
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, toggleSound, enabled) ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound)
} }
} }
@Composable @Composable
fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { fun AudioCallInfoView(call: Call) {
@Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) = Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, color = Color(0xFFFFFFD8), style = style)
Column(horizontalAlignment = alignment) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2) InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
InfoText(call.callState.text) InfoText(call.callState.text)
@ -375,6 +418,21 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
} }
} }
@Composable
fun VideoCallInfoView(call: Call) {
Column(horizontalAlignment = Alignment.Start) {
InfoText(call.callState.text)
val connInfo = call.connectionInfo
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText)
}
}
@Composable
fun InfoText(text: String, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.body2) =
Text(text, modifier, color = Color(0xFFFFFFD8), style = style)
@Composable @Composable
private fun DisabledBackgroundCallsButton() { private fun DisabledBackgroundCallsButton() {
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) } var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
@ -452,7 +510,6 @@ private fun DisabledBackgroundCallsButton() {
@Composable @Composable
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) } val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
permissions = listOf( permissions = listOf(
@ -475,10 +532,10 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
webView.value?.destroy() // val wv = webView.value
// if (wv != null) processCommand(wv, WCallCommand.End)
// webView.value?.destroy()
webView.value = null webView.value = null
} }
} }
@ -505,7 +562,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AndroidView( AndroidView(
factory = { AndroidViewContext -> factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply { (staticWebView ?: WebView(androidAppContext)).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@ -530,7 +587,11 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
webViewSettings.javaScriptEnabled = true webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/android/call.html") if (staticWebView == null) {
this.loadUrl("file:android_asset/www/android/call.html")
} else {
webView.value = this
}
} }
} }
) { /* WebView */ } ) { /* WebView */ }
@ -566,6 +627,7 @@ private class LocalContentWebViewClient(val webView: MutableState<WebView?>, pri
super.onPageFinished(view, url) super.onPageFinished(view, url)
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = view webView.value = view
staticWebView = view
Log.d(TAG, "WebRTCView: webview ready") Log.d(TAG, "WebRTCView: webview ready")
// for debugging // for debugging
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
@ -579,6 +641,7 @@ fun PreviewActiveCallOverlayVideo() {
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = Call( call = Call(
remoteHostId = null, remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData, contact = Contact.sampleData,
callState = CallState.Negotiated, callState = CallState.Negotiated,
localMedia = CallMediaType.Video, localMedia = CallMediaType.Video,
@ -605,6 +668,7 @@ fun PreviewActiveCallOverlayAudio() {
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = Call( call = Call(
remoteHostId = null, remoteHostId = null,
userProfile = Profile.sampleData,
contact = Contact.sampleData, contact = Contact.sampleData,
callState = CallState.Negotiated, callState = CallState.Negotiated,
localMedia = CallMediaType.Audio, localMedia = CallMediaType.Audio,

View file

@ -1,8 +1,112 @@
package chat.simplex.common.views.chatlist package chat.simplex.common.views.chatlist
import android.app.Activity
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
import chat.simplex.common.model.durationText
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
private val CALL_TOP_OFFSET = (-10).dp
private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFFSET
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable @Composable
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {} actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
GreenLine(call)
}
Box(
Modifier
.offset(y = CALL_BOTTOM_ICON_OFFSET)
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
val media = call.peerMedia ?: call.localMedia
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), null, Modifier.size(27.dp).offset(x = 2.5.dp, y = 2.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), null, Modifier.size(27.dp).offset(x = -0.5.dp, y = 2.dp), tint = Color.White)
}
}
}
}
@Composable
private fun GreenLine(call: Call) {
Row(
Modifier
.fillMaxSize()
.background(SimplexGreen)
.padding(top = -CALL_TOP_OFFSET)
.padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
ContactName(call.contact.displayName)
Spacer(Modifier.weight(1f))
CallDuration(call)
}
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
window.statusBarColor = SimplexGreen.toArgb()
onDispose {
window.statusBarColor = Color.Black.toArgb()
}
}
}
@Composable
private fun ContactName(name: String) {
Text(name, Modifier.width(windowWidth() * 0.35f), color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun CallDuration(call: Call) {
val connectedAt = call.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val text = time.value
val sp40Or50 = with(LocalDensity.current) { if (text.length >= 6) 60.sp.toDp() else 42.sp.toDp() }
val offset = with(LocalDensity.current) { 7.sp.toDp() }
Text(text, Modifier.offset(x = offset).widthIn(min = sp40Or50), color = Color.White)
}
}

View file

@ -1,16 +1,19 @@
package chat.simplex.common package chat.simplex.common
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background import androidx.compose.foundation.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
@ -20,8 +23,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateFirstProfile import chat.simplex.common.views.CreateFirstProfile
import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.SplashView import chat.simplex.common.views.SplashView
import chat.simplex.common.views.call.ActiveCallView import chat.simplex.common.views.call.*
import chat.simplex.common.views.call.IncomingCallAlertView
import chat.simplex.common.views.chat.ChatView import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView import chat.simplex.common.views.database.DatabaseErrorView
@ -169,7 +171,17 @@ fun MainScreen() {
} }
} else { } else {
if (chatModel.showCallView.value) { if (chatModel.showCallView.value) {
ActiveCallView() if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
// This if prevents running the activity in the following condition:
// - the activity already started before and was destroyed by collapsing active call (start audio call, press back button, go to a launcher)
if (!chatModel.activeCallViewIsCollapsed.value) {
platform.androidStartCallActivity(false)
}
}
} else {
ActiveCallView()
}
} else { } else {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked // It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView() ModalManager.fullscreen.showPasscodeInView()
@ -206,9 +218,13 @@ fun MainScreen() {
} }
} }
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable @Composable
fun AndroidScreen(settingsState: SettingsViewState) { fun AndroidScreen(settingsState: SettingsViewState) {
BoxWithConstraints { BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) } var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) } val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box( Box(
@ -216,6 +232,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
.graphicsLayer { .graphicsLayer {
translationX = -offset.value.dp.toPx() translationX = -offset.value.dp.toPx()
} }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) { ) {
StartPartOfScreen(settingsState) StartPartOfScreen(settingsState)
} }
@ -242,11 +259,17 @@ fun AndroidScreen(settingsState: SettingsViewState) {
} }
} }
} }
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{ Box(Modifier
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) Box2@{
currentChatId?.let { currentChatId?.let {
ChatView(it, chatModel, onComposed) ChatView(it, chatModel, onComposed)
} }
} }
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
}
} }
} }

View file

@ -96,6 +96,7 @@ object ChatModel {
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null) val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null) val activeCall = mutableStateOf<Call?>(null)
val activeCallViewIsVisible = mutableStateOf<Boolean>(false) val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
val activeCallViewIsCollapsed = mutableStateOf<Boolean>(false)
val callCommand = mutableStateListOf<WCallCommand>() val callCommand = mutableStateListOf<WCallCommand>()
val showCallView = mutableStateOf(false) val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false) val switchingCall = mutableStateOf(false)

View file

@ -1900,10 +1900,8 @@ object ChatController {
if (invitation != null) { if (invitation != null) {
chatModel.callManager.reportCallRemoteEnded(invitation = invitation) chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
} }
withCall(r, r.contact) { _ -> withCall(r, r.contact) { call ->
chatModel.callCommand.add(WCallCommand.End) withBGApi { chatModel.callManager.endCall(call) }
chatModel.activeCall.value = null
chatModel.showCallView.value = false
} }
} }
is CR.ContactSwitch -> is CR.ContactSwitch ->

View file

@ -1,16 +1,21 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode import chat.simplex.common.model.NotificationsMode
interface PlatformInterface { interface PlatformInterface {
suspend fun androidServiceStart() {} suspend fun androidServiceStart() {}
fun androidServiceSafeStop() {} fun androidServiceSafeStop() {}
fun androidCallServiceSafeStop() {}
fun androidNotificationsModeChanged(mode: NotificationsMode) {} fun androidNotificationsModeChanged(mode: NotificationsMode) {}
fun androidChatStartedAfterBeingOff() {} fun androidChatStartedAfterBeingOff() {}
fun androidChatStopped() {} fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {} fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {} fun androidSetNightModeIfSupported() {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
} }
/** /**

View file

@ -1,6 +1,6 @@
package chat.simplex.common.views.call package chat.simplex.common.views.call
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi import chat.simplex.common.views.helpers.withBGApi
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@ -23,27 +23,29 @@ class CallManager(val chatModel: ChatModel) {
} }
} }
fun acceptIncomingCall(invitation: RcvCallInvitation) { fun acceptIncomingCall(invitation: RcvCallInvitation) = withBGApi {
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
if (call == null) { val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
justAcceptIncomingCall(invitation = invitation) val profile = contactInfo?.second ?: invitation.user.profile.toProfile()
// In case the same contact calling while previous call didn't end yet (abnormal ending of call from the other side)
if (call == null || (call.remoteHostId == invitation.remoteHostId && call.contact.id == invitation.contact.id)) {
justAcceptIncomingCall(invitation = invitation, profile)
} else { } else {
withBGApi { chatModel.switchingCall.value = true
chatModel.switchingCall.value = true try {
try { endCall(call = call)
endCall(call = call) justAcceptIncomingCall(invitation = invitation, profile)
justAcceptIncomingCall(invitation = invitation) } finally {
} finally { chatModel.switchingCall.value = false
chatModel.switchingCall.value = false
}
} }
} }
} }
private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
with (chatModel) { with (chatModel) {
activeCall.value = Call( activeCall.value = Call(
remoteHostId = invitation.remoteHostId, remoteHostId = invitation.remoteHostId,
userProfile = userProfile,
contact = invitation.contact, contact = invitation.contact,
callState = CallState.InvitationAccepted, callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media, localMedia = invitation.callType.media,
@ -68,17 +70,23 @@ class CallManager(val chatModel: ChatModel) {
} }
suspend fun endCall(call: Call) { suspend fun endCall(call: Call) {
with (chatModel) { with(chatModel) {
// If there is active call currently and it's with other contact, don't interrupt it
if (activeCall.value != null && !(activeCall.value?.remoteHostId == call.remoteHostId && activeCall.value?.contact?.id == call.contact.id)) return
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
showCallView.value = false
activeCall.value = null
activeCallViewIsCollapsed.value = false
platform.androidCallEnded()
}
if (call.callState == CallState.Ended) { if (call.callState == CallState.Ended) {
Log.d(TAG, "CallManager.endCall: call ended") Log.d(TAG, "CallManager.endCall: call ended")
activeCall.value = null
showCallView.value = false
} else { } else {
Log.d(TAG, "CallManager.endCall: ending call...") Log.d(TAG, "CallManager.endCall: ending call...")
callCommand.add(WCallCommand.End) //callCommand.add(WCallCommand.End)
showCallView.value = false
controller.apiEndCall(call.remoteHostId, call.contact) controller.apiEndCall(call.remoteHostId, call.contact)
activeCall.value = null
} }
} }
} }

View file

@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URI import java.net.URI
import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
data class Call( data class Call(
val remoteHostId: Long?, val remoteHostId: Long?,
val userProfile: Profile,
val contact: Contact, val contact: Contact,
val callState: CallState, val callState: CallState,
val localMedia: CallMediaType, val localMedia: CallMediaType,
@ -23,7 +23,7 @@ data class Call(
val soundSpeaker: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User, var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null, val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null var connectedAt: Instant? = null,
) { ) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
@ -36,6 +36,9 @@ data class Call(
} }
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
} }
enum class CallState { enum class CallState {
@ -75,6 +78,7 @@ sealed class WCallCommand {
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand() @Serializable @SerialName("end") object End: WCallCommand()
} }
@ -167,6 +171,13 @@ enum class VideoCamera {
val flipped: VideoCamera get() = if (this == User) Environment else User val flipped: VideoCamera get() = if (this == User) Environment else User
} }
@Serializable
enum class LayoutType {
@SerialName("default") Default,
@SerialName("localVideo") LocalVideo,
@SerialName("remoteVideo") RemoteVideo
}
@Serializable @Serializable
data class ConnectionState( data class ConnectionState(
val connectionState: String, val connectionState: String,

View file

@ -301,7 +301,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi { withBGApi {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) { if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media)) chatModel.callCommand.add(WCallCommand.Capabilities(media))
} }
@ -673,7 +675,7 @@ fun ChatInfoToolbar(
} }
} }
} }
} else if (activeCall?.contact?.id == chat.id) { } else if (activeCall?.contact?.id == chat.id && appPlatform.isDesktop) {
barButtons.add { barButtons.add {
val call = remember { chatModel.activeCall }.value val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt val connectedAt = call?.connectedAt

View file

@ -29,6 +29,7 @@ import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.* import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -121,7 +122,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
} }
} }
if (searchText.value.text.isEmpty()) { if (searchText.value.text.isEmpty()) {
DesktopActiveCallOverlayLayout(newChatSheetState) if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch // TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) { tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
@ -314,7 +320,7 @@ private fun ToggleFilterDisabledButton() {
} }
@Composable @Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link") Log.d(TAG, "connectIfOpenedViaUri: opened via link")

View file

@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
userPickerState.value = AnimatedViewState.VISIBLE userPickerState.value = AnimatedViewState.VISIBLE
} }
} }
else -> NavigationButtonBack { chatModel.sharedContent.value = null } else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
} }
} }
if (chatModel.chats.size >= 8) { if (chatModel.chats.size >= 8) {

View file

@ -18,7 +18,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
@Composable @Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) { fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (showClose) { if (showClose) {
NavigationButtonBack(onButtonClicked = close) NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else { } else {
Spacer(Modifier) Spacer(Modifier)
} }

View file

@ -44,10 +44,10 @@ fun DefaultTopAppBar(
} }
@Composable @Composable
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) { fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) {
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
Icon( Icon(
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = tintColor
) )
} }
} }

View file

@ -29,7 +29,7 @@ fun ModalView(
} }
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(close, showClose, endButtons) CloseSheetBar(close, showClose, endButtons = endButtons)
Box(modifier) { content() } Box(modifier) { content() }
} }
} }

View file

@ -179,6 +179,9 @@
<!-- SimpleX Chat foreground Service --> <!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title">SimpleX Chat service</string> <string name="simplex_service_notification_title">SimpleX Chat service</string>
<string name="simplex_service_notification_text">Receiving messages…</string> <string name="simplex_service_notification_text">Receiving messages…</string>
<string name="call_service_notification_audio_call">Audio call</string>
<string name="call_service_notification_video_call">Video call</string>
<string name="call_service_notification_end_call">End call</string>
<string name="hide_notification">Hide</string> <string name="hide_notification">Hide</string>
<!-- Notification channels --> <!-- Notification channels -->

View file

@ -8,6 +8,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@ -15,6 +16,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View file

@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 30%; width: 30%;
max-width: 30%; max-width: 30%;
@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View file

@ -11,6 +11,12 @@ var VideoCamera;
VideoCamera["User"] = "user"; VideoCamera["User"] = "user";
VideoCamera["Environment"] = "environment"; VideoCamera["Environment"] = "environment";
})(VideoCamera || (VideoCamera = {})); })(VideoCamera || (VideoCamera = {}));
var LayoutType;
(function (LayoutType) {
LayoutType["Default"] = "default";
LayoutType["LocalVideo"] = "localVideo";
LayoutType["RemoteVideo"] = "remoteVideo";
})(LayoutType || (LayoutType = {}));
// for debugging // for debugging
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp})) // var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
@ -319,6 +325,10 @@ const processCommand = (function () {
localizedDescription = command.description; localizedDescription = command.description;
resp = { type: "ok" }; resp = { type: "ok" };
break; break;
case "layout":
changeLayout(command.layout);
resp = { type: "ok" };
break;
case "end": case "end":
endCall(); endCall();
resp = { type: "ok" }; resp = { type: "ok" };
@ -607,6 +617,28 @@ function toggleMedia(s, media) {
} }
return res; return res;
} }
function changeLayout(layout) {
const local = document.getElementById("local-video-stream");
const remote = document.getElementById("remote-video-stream");
switch (layout) {
case LayoutType.Default:
local.className = "inline";
remote.className = "inline";
local.style.visibility = "visible";
remote.style.visibility = "visible";
break;
case LayoutType.LocalVideo:
local.className = "fullscreen";
local.style.visibility = "visible";
remote.style.visibility = "hidden";
break;
case LayoutType.RemoteVideo:
remote.className = "fullscreen";
local.style.visibility = "hidden";
remote.style.visibility = "visible";
break;
}
}
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() { function callCryptoFunction() {
const initialPlainTextRequired = { const initialPlainTextRequired = {

View file

@ -9,6 +9,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@ -16,6 +17,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View file

@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View file

@ -3,7 +3,6 @@ package chat.simplex.common.views.chatlist
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -13,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@ -22,10 +22,9 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) { actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
val call = remember { chatModel.activeCall}.value // if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { if (!newChatSheetState.collectAsState().value.isVisible()) {
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) } val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider( CompositionLocalProvider(

View file

@ -8,6 +8,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@ -15,6 +16,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View file

@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 30%; width: 30%;
max-width: 30%; max-width: 30%;
@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;

View file

@ -16,6 +16,7 @@ type WCallCommand =
| WCEnableMedia | WCEnableMedia
| WCToggleCamera | WCToggleCamera
| WCDescription | WCDescription
| WCLayout
| WCEndCall | WCEndCall
type WCallResponse = type WCallResponse =
@ -31,7 +32,7 @@ type WCallResponse =
| WRError | WRError
| WCAcceptOffer | WCAcceptOffer
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "layout" | "end"
type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error"
@ -45,6 +46,12 @@ enum VideoCamera {
Environment = "environment", Environment = "environment",
} }
enum LayoutType {
Default = "default",
LocalVideo = "localVideo",
RemoteVideo = "remoteVideo",
}
interface IWCallCommand { interface IWCallCommand {
type: WCallCommandTag type: WCallCommandTag
} }
@ -115,6 +122,11 @@ interface WCDescription extends IWCallCommand {
description: string description: string
} }
interface WCLayout extends IWCallCommand {
type: "layout"
layout: LayoutType
}
interface WRCapabilities extends IWCallResponse { interface WRCapabilities extends IWCallResponse {
type: "capabilities" type: "capabilities"
capabilities: CallCapabilities capabilities: CallCapabilities
@ -515,6 +527,10 @@ const processCommand = (function () {
localizedDescription = command.description localizedDescription = command.description
resp = {type: "ok"} resp = {type: "ok"}
break break
case "layout":
changeLayout(command.layout)
resp = {type: "ok"}
break
case "end": case "end":
endCall() endCall()
resp = {type: "ok"} resp = {type: "ok"}
@ -824,6 +840,29 @@ function toggleMedia(s: MediaStream, media: CallMediaType): boolean {
return res return res
} }
function changeLayout(layout: LayoutType) {
const local = document.getElementById("local-video-stream")!
const remote = document.getElementById("remote-video-stream")!
switch (layout) {
case LayoutType.Default:
local.className = "inline"
remote.className = "inline"
local.style.visibility = "visible"
remote.style.visibility = "visible"
break
case LayoutType.LocalVideo:
local.className = "fullscreen"
local.style.visibility = "visible"
remote.style.visibility = "hidden"
break
case LayoutType.RemoteVideo:
remote.className = "fullscreen"
local.style.visibility = "hidden"
remote.style.visibility = "visible"
break
}
}
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void> type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
interface CallCrypto { interface CallCrypto {

View file

@ -9,6 +9,7 @@
<body> <body>
<video <video
id="remote-video-stream" id="remote-video-stream"
class="inline"
autoplay autoplay
playsinline playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII=" poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
@ -16,6 +17,7 @@
></video> ></video>
<video <video
id="local-video-stream" id="local-video-stream"
class="inline"
muted muted
autoplay autoplay
playsinline playsinline

View file

@ -5,14 +5,14 @@ body {
background-color: black; background-color: black;
} }
#remote-video-stream { #remote-video-stream.inline {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
#local-video-stream { #local-video-stream.inline {
position: absolute; position: absolute;
width: 20%; width: 20%;
max-width: 20%; max-width: 20%;
@ -23,6 +23,20 @@ body {
right: 0; right: 0;
} }
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls { *::-webkit-media-controls {
display: none !important; display: none !important;
-webkit-appearance: none !important; -webkit-appearance: none !important;