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 @@