mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
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:
parent
09bbaa1c94
commit
5da8aef794
33 changed files with 933 additions and 189 deletions
|
@ -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"
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue