diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml
index d8350ee222..073f1bf8c8 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/multiplatform/android/src/main/AndroidManifest.xml
@@ -103,11 +103,14 @@
-
-
+ android:launchMode="singleInstance"
+ android:supportsPictureInPicture="true"
+ android:autoRemoveFromRecents="true"
+ android:screenOrientation="portrait"
+ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"/>
+
+
+
+
+
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))
+ }
+ }
+}
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
index e9f28a8ea7..422d886d7c 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -1,14 +1,15 @@
package chat.simplex.app
-import android.app.Application
+import android.app.*
import android.content.Context
-import androidx.compose.ui.platform.ClipboardManager
import chat.simplex.common.platform.Log
-import android.app.UiModeManager
+import android.content.Intent
import android.os.*
import androidx.lifecycle.*
import androidx.work.*
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.requiresIgnoringBattery
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.DefaultTheme
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.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
@@ -184,6 +186,10 @@ class SimplexApp: Application(), LifecycleEventObserver {
SimplexService.safeStopService()
}
+ override fun androidCallServiceSafeStop() {
+ CallService.stopService()
+ }
+
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
if (mode.requiresIgnoringBattery && !SimplexService.isBackgroundAllowed()) {
appPrefs.backgroundServiceNoticeShown.set(false)
@@ -254,6 +260,28 @@ class SimplexApp: Application(), LifecycleEventObserver {
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 {
if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred = CompletableDeferred()
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
index 903f096080..b0600e6d59 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt
@@ -34,12 +34,13 @@ import kotlin.system.exitProcess
class SimplexService: Service() {
private var wakeLock: PowerManager.WakeLock? = null
- private var isStartingService = false
+ private var isCheckingNewMessages = false
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")
+ isServiceStarting = false
if (intent != null) {
val action = intent.action
Log.d(TAG, "intent action $action")
@@ -71,6 +72,7 @@ class SimplexService: Service() {
stopForeground(true)
stopSelf()
} else {
+ isServiceStarting = false
isServiceStarted = true
// 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) {
@@ -89,6 +91,7 @@ class SimplexService: Service() {
} catch (e: Exception) {
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
}
+ isServiceStarting = false
isServiceStarted = false
stopAfterStart = false
saveServiceState(this, ServiceState.STOPPED)
@@ -101,9 +104,9 @@ class SimplexService: Service() {
private fun startService() {
Log.d(TAG, "SimplexService startService")
- if (wakeLock != null || isStartingService) return
+ if (wakeLock != null || isCheckingNewMessages) return
val self = this
- isStartingService = true
+ isCheckingNewMessages = true
withLongRunningApi {
val chatController = ChatController
waitDbMigrationEnds(chatController)
@@ -123,7 +126,7 @@ class SimplexService: Service() {
}
}
} 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 WORK_NAME_ONCE = "ServiceStartWorkerOnce"
+ var isServiceStarting = false
var isServiceStarted = false
private var stopAfterStart = false
@@ -281,7 +285,7 @@ class SimplexService: Service() {
fun safeStopService() {
if (isServiceStarted) {
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
- } else {
+ } else if (isServiceStarting) {
stopAfterStart = true
}
}
@@ -291,6 +295,7 @@ class SimplexService: Service() {
withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also {
it.action = action.name
+ isServiceStarting = true
ContextCompat.startForegroundService(androidAppContext, it)
}
}
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
index d32508c7b0..7673658cfb 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.*
import chat.simplex.app.*
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.common.views.helpers.*
import chat.simplex.common.model.*
@@ -33,6 +33,7 @@ object NtfManager {
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_2"
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
+ const val EndCallAction: String = "chat.simplex.app.END_CALL"
const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId"
@@ -157,7 +158,7 @@ object NtfManager {
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
var ntfBuilder =
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)
NotificationCompat.Builder(context, CallChannel)
.setFullScreenIntent(fullScreenPendingIntent, true)
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
similarity index 53%
rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
rename to apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
index d09cb019f8..6b1b01db99 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt
@@ -1,17 +1,18 @@
package chat.simplex.app.views.call
-import android.app.Activity
-import android.app.KeyguardManager
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import chat.simplex.common.platform.Log
-import android.view.WindowManager
+import android.app.*
+import android.content.*
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.os.*
+import android.util.Rational
+import android.view.*
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.trackPipAnimationHintView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.painter.Painter
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
import chat.simplex.app.*
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.app.model.NtfManager.OpenChatAction
-import chat.simplex.common.platform.ntfManager
+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.res.MR
import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.launch
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?) {
super.onCreate(savedInstanceState)
- setContent { IncomingCallActivityView(ChatModel) }
- unlockForIncomingCall()
+ callActivity = WeakReference(this)
+ 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() {
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() {
@@ -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 {
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 =
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
+
@Composable
-fun IncomingCallActivityView(m: ChatModel) {
+fun CallActivityView() {
val switchingCall = m.switchingCall.value
val invitation = m.activeCallInvitation.value
- val call = m.activeCall.value
+ val call = remember { m.activeCall }.value
val showCallView = m.showCallView.value
- val activity = LocalContext.current as Activity
- LaunchedEffect(invitation, call, switchingCall, showCallView) {
- if (!switchingCall && invitation == null && (!showCallView || call == null)) {
- Log.d(TAG, "IncomingCallActivityView: finishing activity")
- activity.finish()
- }
+ val activity = LocalContext.current as CallActivity
+ LaunchedEffect(Unit) {
+ snapshotFlow { m.activeCallViewIsCollapsed.value }
+ .collect { collapsed ->
+ 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 {
- Surface(
- Modifier
- .fillMaxSize(),
- color = MaterialTheme.colors.background,
- contentColor = LocalContentColor.current
- ) {
- if (showCallView) {
- Box {
- ActiveCallView()
- if (invitation != null) IncomingCallAlertView(invitation, m)
+ var prevCall by remember { mutableStateOf(call) }
+ KeyChangeEffect(m.activeCall.value) {
+ if (m.activeCall.value != null) {
+ prevCall = m.activeCall.value
+ activity.boundService?.updateNotification()
+ }
+ }
+ Box(Modifier.background(Color.Black)) {
+ if (call != null) {
+ 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
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
@@ -135,7 +293,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
val intent = Intent(context, MainActivity::class.java)
- .setAction(OpenChatAction)
+ .setAction(NtfManager.OpenChatAction)
.putExtra("userId", invitation.user.userId)
.putExtra("chatId", invitation.contact.id)
context.startActivity(intent)
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
index 90b18bde93..547db51bad 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.net.LocalServerSocket
import android.util.Log
+import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity
import chat.simplex.common.*
import chat.simplex.common.platform.*
@@ -25,7 +26,8 @@ val defaultLocale: Locale = Locale.getDefault()
@SuppressLint("StaticFieldLeak")
lateinit var androidAppContext: Context
-lateinit var mainActivity: WeakReference
+var mainActivity: WeakReference = WeakReference(null)
+var callActivity: WeakReference = WeakReference(null)
fun initHaskell() {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt
index ac14b5199f..d6ba0d8e0d 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
@@ -50,20 +51,30 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString
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")
@Composable
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 ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
- LaunchedEffect(Unit) {
- // Start service when call happening since it's not already started.
- // It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
- if (!ntfModeService) platform.androidServiceStart()
+ val proximityLock = remember {
+ val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
+ if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
+ pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
+ } else {
+ null
+ }
}
DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -93,22 +104,24 @@ actual fun ActiveCallView() {
}
}
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 {
- // Stop it when call ended
- if (!ntfModeService) platform.androidServiceSafeStop()
dropAudioManagerOverrides()
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 call = chatModel.activeCall.value
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
@@ -156,7 +169,6 @@ actual fun ActiveCallView() {
is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
withBGApi { chatModel.callManager.endCall(call) }
- chatModel.showCallView.value = false
}
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
is WCallCommand.Answer ->
@@ -173,8 +185,9 @@ actual fun ActiveCallView() {
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
}
}
- is WCallCommand.End ->
- chatModel.showCallView.value = false
+ is WCallCommand.End -> {
+ withBGApi { chatModel.callManager.endCall(call) }
+ }
else -> {}
}
is WCallResponse.Error -> {
@@ -183,8 +196,16 @@ actual fun ActiveCallView() {
}
}
}
- val call = chatModel.activeCall.value
- if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
+ val showOverlay = when {
+ 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
@@ -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) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
@@ -271,59 +306,69 @@ private fun dropAudioManagerOverrides() {
private fun ActiveCallOverlayLayout(
call: Call,
speakerCanBeEnabled: Boolean,
+ enabled: Boolean = true,
dismiss: () -> Unit,
toggleAudio: () -> Unit,
toggleVideo: () -> Unit,
toggleSound: () -> Unit,
flipCamera: () -> Unit
) {
- Column(Modifier.padding(DEFAULT_PADDING)) {
- when (call.peerMedia ?: call.localMedia) {
- CallMediaType.Video -> {
- CallInfoView(call, alignment = Alignment.Start)
- Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
- 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)
- }
- }
+ Column {
+ val media = call.peerMedia ?: call.localMedia
+ CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
+ if (media == CallMediaType.Video) {
+ Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
- 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)
- CallInfoView(call, alignment = Alignment.CenterHorizontally)
- }
- Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
- DisabledBackgroundCallsButton()
- }
- Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
- Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
- 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))
+ }
+ Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
+ when (media) {
+ CallMediaType.Video -> {
+ VideoCallInfoView(call)
+ Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
+ DisabledBackgroundCallsButton()
+ }
+ Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ ToggleAudioButton(call, enabled, toggleAudio)
+ Spacer(Modifier.size(40.dp))
+ 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))
+ }
+ if (call.videoEnabled) {
+ ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
+ 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.padding(end = 32.dp)) {
- ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
+ Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
+ DisabledBackgroundCallsButton()
+ }
+ 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
-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) {
IconButton(onClick = action, enabled = enabled) {
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
-private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
+private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
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 {
- 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
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
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 {
- 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
-fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
- @Composable fun InfoText(text: String, style: TextStyle = MaterialTheme.typography.body2) =
- Text(text, color = Color(0xFFFFFFD8), style = style)
- Column(horizontalAlignment = alignment) {
+fun AudioCallInfoView(call: Call) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
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
private fun DisabledBackgroundCallsButton() {
var show by remember { mutableStateOf(!platform.androidIsBackgroundCallAllowed()) }
@@ -452,7 +510,6 @@ private fun DisabledBackgroundCallsButton() {
@Composable
fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) {
- val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
@@ -475,10 +532,10 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
- val wv = webView.value
- if (wv != null) processCommand(wv, WCallCommand.End)
lifecycleOwner.lifecycle.removeObserver(observer)
- webView.value?.destroy()
+// val wv = webView.value
+// if (wv != null) processCommand(wv, WCallCommand.End)
+// webView.value?.destroy()
webView.value = null
}
}
@@ -505,7 +562,7 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
- WebView(AndroidViewContext).apply {
+ (staticWebView ?: WebView(androidAppContext)).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -530,7 +587,11 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
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 */ }
@@ -566,6 +627,7 @@ private class LocalContentWebViewClient(val webView: MutableState, pri
super.onPageFinished(view, url)
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = view
+ staticWebView = view
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
@@ -579,6 +641,7 @@ fun PreviewActiveCallOverlayVideo() {
ActiveCallOverlayLayout(
call = Call(
remoteHostId = null,
+ userProfile = Profile.sampleData,
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Video,
@@ -605,6 +668,7 @@ fun PreviewActiveCallOverlayAudio() {
ActiveCallOverlayLayout(
call = Call(
remoteHostId = null,
+ userProfile = Profile.sampleData,
contact = Contact.sampleData,
callState = CallState.Negotiated,
localMedia = CallMediaType.Audio,
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt
index cb74664a48..4a8b912cdd 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt
@@ -1,8 +1,112 @@
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.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.res.MR
+import dev.icerock.moko.resources.compose.painterResource
+import kotlinx.coroutines.delay
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
-actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) {}
+actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) {
+ 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)
+ }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
index 57959af4c6..0213350916 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
@@ -1,16 +1,19 @@
package chat.simplex.common
import androidx.compose.animation.core.Animatable
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+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.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
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.helpers.SimpleButton
import chat.simplex.common.views.SplashView
-import chat.simplex.common.views.call.ActiveCallView
-import chat.simplex.common.views.call.IncomingCallAlertView
+import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView
@@ -169,7 +171,17 @@ fun MainScreen() {
}
} else {
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 {
// It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked
ModalManager.fullscreen.showPasscodeInView()
@@ -206,9 +218,13 @@ fun MainScreen() {
}
}
+val ANDROID_CALL_TOP_PADDING = 40.dp
+
@Composable
fun AndroidScreen(settingsState: SettingsViewState) {
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) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
Box(
@@ -216,6 +232,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
.graphicsLayer {
translationX = -offset.value.dp.toPx()
}
+ .padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
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 {
ChatView(it, chatModel, onComposed)
}
}
+ if (call != null && showCallArea) {
+ ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
+ }
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index c19abdccc9..a87e7c45bb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -96,6 +96,7 @@ object ChatModel {
val activeCallInvitation = mutableStateOf(null)
val activeCall = mutableStateOf(null)
val activeCallViewIsVisible = mutableStateOf(false)
+ val activeCallViewIsCollapsed = mutableStateOf(false)
val callCommand = mutableStateListOf()
val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index ce65ec5121..90a4261025 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -1900,10 +1900,8 @@ object ChatController {
if (invitation != null) {
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
}
- withCall(r, r.contact) { _ ->
- chatModel.callCommand.add(WCallCommand.End)
- chatModel.activeCall.value = null
- chatModel.showCallView.value = false
+ withCall(r, r.contact) { call ->
+ withBGApi { chatModel.callManager.endCall(call) }
}
}
is CR.ContactSwitch ->
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
index e55c2c939a..8ce92f6154 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt
@@ -1,16 +1,21 @@
package chat.simplex.common.platform
+import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
interface PlatformInterface {
suspend fun androidServiceStart() {}
fun androidServiceSafeStop() {}
+ fun androidCallServiceSafeStop() {}
fun androidNotificationsModeChanged(mode: NotificationsMode) {}
fun androidChatStartedAfterBeingOff() {}
fun androidChatStopped() {}
fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
+ fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
+ fun androidPictureInPictureAllowed(): Boolean = true
+ fun androidCallEnded() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
}
/**
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt
index b215badf2d..285658ec1d 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt
@@ -1,6 +1,6 @@
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.views.helpers.withBGApi
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
- if (call == null) {
- justAcceptIncomingCall(invitation = invitation)
+ val contactInfo = chatModel.controller.apiContactInfo(invitation.remoteHostId, invitation.contact.contactId)
+ 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 {
- withBGApi {
- chatModel.switchingCall.value = true
- try {
- endCall(call = call)
- justAcceptIncomingCall(invitation = invitation)
- } finally {
- chatModel.switchingCall.value = false
- }
+ chatModel.switchingCall.value = true
+ try {
+ endCall(call = call)
+ justAcceptIncomingCall(invitation = invitation, profile)
+ } finally {
+ chatModel.switchingCall.value = false
}
}
}
- private fun justAcceptIncomingCall(invitation: RcvCallInvitation) {
+ private fun justAcceptIncomingCall(invitation: RcvCallInvitation, userProfile: Profile) {
with (chatModel) {
activeCall.value = Call(
remoteHostId = invitation.remoteHostId,
+ userProfile = userProfile,
contact = invitation.contact,
callState = CallState.InvitationAccepted,
localMedia = invitation.callType.media,
@@ -68,17 +70,23 @@ class CallManager(val chatModel: ChatModel) {
}
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) {
Log.d(TAG, "CallManager.endCall: call ended")
- activeCall.value = null
- showCallView.value = false
} else {
Log.d(TAG, "CallManager.endCall: ending call...")
- callCommand.add(WCallCommand.End)
- showCallView.value = false
+ //callCommand.add(WCallCommand.End)
controller.apiEndCall(call.remoteHostId, call.contact)
- activeCall.value = null
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt
index 223a8a020f..754878e9fb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt
@@ -7,11 +7,11 @@ import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
-import java.util.*
import kotlin.collections.ArrayList
data class Call(
val remoteHostId: Long?,
+ val userProfile: Profile,
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
@@ -23,7 +23,7 @@ data class Call(
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null,
- var connectedAt: Instant? = null
+ var connectedAt: Instant? = null,
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
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
+
+ fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
+
}
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("camera") data class Camera(val camera: VideoCamera): 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()
}
@@ -167,6 +171,13 @@ enum class VideoCamera {
val flipped: VideoCamera get() = if (this == User) Environment else User
}
+@Serializable
+enum class LayoutType {
+ @SerialName("default") Default,
+ @SerialName("localVideo") LocalVideo,
+ @SerialName("remoteVideo") RemoteVideo
+}
+
@Serializable
data class ConnectionState(
val connectionState: String,
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 9a92997f8e..6e30bd11ca 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -301,7 +301,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi {
val cInfo = chat.chatInfo
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.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 {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
index 3a47d062ac..1380f9ccc4 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt
@@ -29,6 +29,7 @@ import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
+import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@@ -121,7 +122,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
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
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
@@ -314,7 +320,7 @@ private fun ToggleFilterDisabledButton() {
}
@Composable
-expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow)
+expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
index b1e87823c8..e7b0bdfba3 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt
@@ -85,7 +85,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
userPickerState.value = AnimatedViewState.VISIBLE
}
}
- else -> NavigationButtonBack { chatModel.sharedContent.value = null }
+ else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null })
}
}
if (chatModel.chats.size >= 8) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
index 1a29a334a8..2fb27e29b1 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt
@@ -18,7 +18,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@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(
Modifier
.fillMaxWidth()
@@ -35,7 +35,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
- NavigationButtonBack(onButtonClicked = close)
+ NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else {
Spacer(Modifier)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt
index 93be24d921..577411c7e3 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt
@@ -44,10 +44,10 @@ fun DefaultTopAppBar(
}
@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) {
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
)
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
index e2dd315fb0..ce4d8da47f 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
@@ -29,7 +29,7 @@ fun ModalView(
}
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
- CloseSheetBar(close, showClose, endButtons)
+ CloseSheetBar(close, showClose, endButtons = endButtons)
Box(modifier) { content() }
}
}
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index a511e2e13d..d9fcbf3a7d 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -179,6 +179,9 @@
SimpleX Chat service
Receiving messages…
+ Audio call
+ Video call
+ End call
Hide
diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html
index 7b51a05151..cbdf7a23a3 100644
--- a/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html
+++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html
@@ -8,6 +8,7 @@