From 8f0538e756b399bc3f8b063c62d82ca926f0bd3b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 19 Nov 2023 09:07:42 +0800 Subject: [PATCH] android: UI for remote connections (#3395) * android: UI for remote connections * camera permissions * eol --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../common/platform/AppCommon.android.kt | 2 + .../common/platform/RecAndPlay.android.kt | 2 +- .../common/views/chat/ScanCodeView.android.kt | 16 - .../common/views/helpers/Utils.android.kt | 2 +- .../views/newchat/QRCodeScanner.android.kt | 6 + .../chat/simplex/common/model/ChatModel.kt | 27 +- .../chat/simplex/common/model/SimpleXAPI.kt | 116 ++++- .../chat/simplex/common/platform/AppCommon.kt | 2 + .../simplex/common/views/chat/ScanCodeView.kt | 5 +- .../common/views/chatlist/UserPicker.kt | 21 + .../simplex/common/views/helpers/Utils.kt | 8 + .../common/views/remote/ConnectDesktopView.kt | 472 ++++++++++++++++++ .../common/views/remote/ConnectMobileView.kt | 2 +- .../common/views/usersettings/SettingsView.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 26 + .../common/platform/AppCommon.desktop.kt | 4 + .../common/views/chat/ScanCodeView.desktop.kt | 8 - 17 files changed, 669 insertions(+), 53 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt 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 192f3dcc29..90b18bde93 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 @@ -16,6 +16,8 @@ import kotlin.random.Random actual val appPlatform = AppPlatform.ANDROID +actual val deviceName = android.os.Build.MODEL + var isAppOnForeground: Boolean = false @Suppress("ConstantLocale") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index f99dea77ca..5b0d3c778f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -38,7 +38,7 @@ actual class RecorderNative: RecorderInterface { rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir) + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) fileToSave.deleteOnExit() val path = fileToSave.absolutePath filePath = path diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt deleted file mode 100644 index 79361dc073..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package chat.simplex.common.views.chat - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.views.chat.ScanCodeLayout -import com.google.accompanist.permissions.rememberPermissionState - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanCodeLayout(verifyCode, close) -} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5c7273ecc8..d244294763 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -295,7 +295,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return try { val ext = if (asPng) "png" else "jpg" tmpDir.mkdir() - return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { + return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext, tmpDir)).apply { outputStream().use { out -> image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) out.flush() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 7fb6445d57..e7453ce20a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import android.Manifest import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup @@ -19,6 +20,7 @@ import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG +import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.* @@ -26,6 +28,10 @@ import java.util.concurrent.* @Composable actual fun QRCodeScanner(onBarcode: (String) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } 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 86e5072307..af946be3cd 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 @@ -27,6 +27,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File import java.net.URI +import java.net.URLDecoder import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -111,6 +112,7 @@ object ChatModel { val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) val newRemoteHostPairing = mutableStateOf?>(null) + val remoteCtrlSession = mutableStateOf(null) fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value @@ -598,7 +600,7 @@ object ChatModel { terminalItems.add(item) } - fun connectedToRemote(): Boolean = currentRemoteHost.value != null + fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } enum class ChatType(val type: String) { @@ -2347,7 +2349,7 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) - fun desktopPlain(f: URI): CryptoFile = CryptoFile(f.rawPath, null) + fun desktopPlain(f: URI): CryptoFile = CryptoFile(URLDecoder.decode(f.rawPath, "UTF-8"), null) } } @@ -2907,8 +2909,18 @@ enum class NotificationPreviewMode { data class RemoteCtrlSession( val ctrlAppInfo: CtrlAppInfo, val appVersion: String, - val sessionState: RemoteCtrlSessionState -) + val sessionState: UIRemoteCtrlSessionState +) { + val active: Boolean + get () = sessionState is UIRemoteCtrlSessionState.Connected + + val sessionCode: String? + get() = when (val s = sessionState) { + is UIRemoteCtrlSessionState.PendingConfirmation -> s.sessionCode + is UIRemoteCtrlSessionState.Connected -> s.sessionCode + else -> null + } +} @Serializable sealed class RemoteCtrlSessionState { @@ -2917,3 +2929,10 @@ sealed class RemoteCtrlSessionState { @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState() @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() } + +sealed class UIRemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() +} 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 0d3b16fa80..253a9fb163 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 @@ -24,7 +24,6 @@ import kotlinx.serialization.* import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.* -import java.io.File import java.util.Date typealias ChatCtrl = Long @@ -167,7 +166,11 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) - val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, "Desktop") + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, deviceName) + + val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) + val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -309,6 +312,9 @@ class AppPreferences { private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" + private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -1430,18 +1436,23 @@ object ChatController { suspend fun getRemoteFile(rhId: Long, file: RemoteFile): Boolean = sendCommandOkResp(CC.GetRemoteFile(rhId, file)) - suspend fun connectRemoteCtrl(invitation: String): SomeRemoteCtrl? { - val r = sendCmd(CC.ConnectRemoteCtrl(invitation)) - if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) - apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r) - return null + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + val r = sendCmd(CC.ConnectRemoteCtrl(desktopAddress)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) return null to r + else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") } suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl()) - suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId)) + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rcId)) - suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode)) + suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { + val r = sendCmd(CC.VerifyRemoteCtrlSession(sessionCode)) + if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) + return null + } suspend fun listRemoteCtrls(): List? { val r = sendCmd(CC.ListRemoteCtrls()) @@ -1843,6 +1854,22 @@ object ChatController { chatModel.newRemoteHostPairing.value = null switchUIRemoteHost(null) } + is CR.RemoteCtrlFound -> { + // TODO multicast + Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + } + is CR.RemoteCtrlSessionCode -> { + val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlConnected -> { + // TODO currently it is returned in response to command, so it is redundant + val state = UIRemoteCtrlSessionState.Connected(remoteCtrl = r.remoteCtrl, sessionCode = chatModel.remoteCtrlSession.value?.sessionCode ?: "") + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlStopped -> { + switchToLocalSession() + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1866,6 +1893,23 @@ object ChatController { } } + fun switchToLocalSession() { + val m = chatModel + m.remoteCtrlSession.value = null + withBGApi { + val users = listUsers() + m.users.clear() + m.users.addAll(users) + getUserChatData() + val statuses = apiGetNetworkStatuses() + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + } + } + private fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId @@ -3474,7 +3518,10 @@ data class RemoteCtrlInfo ( val remoteCtrlId: Long, val ctrlDeviceName: String, val sessionState: RemoteCtrlSessionState? -) +) { + val deviceViewName: String + get() = ctrlDeviceName.ifEmpty { remoteCtrlId.toString() } +} @Serializable data class RemoteHostInfo( @@ -4558,6 +4605,7 @@ sealed class AgentErrorType { is SMP -> "SMP ${smpErr.string}" // is NTF -> "NTF ${ntfErr.string}" is XFTP -> "XFTP ${xftpErr.string}" + is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" is INTERNAL -> "INTERNAL $internalErr" @@ -4568,6 +4616,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() + @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @@ -4738,6 +4787,38 @@ sealed class XFTPErrorType { @Serializable @SerialName("INTERNAL") object INTERNAL: XFTPErrorType() } +@Serializable +sealed class RCErrorType { + val string: String get() = when (this) { + is INTERNAL -> "INTERNAL $internalErr" + is IDENTITY -> "IDENTITY" + is NO_LOCAL_ADDRESS -> "NO_LOCAL_ADDRESS" + is TLS_START_FAILED -> "TLS_START_FAILED" + is EXCEPTION -> "EXCEPTION $EXCEPTION" + is CTRL_AUTH -> "CTRL_AUTH" + is CTRL_NOT_FOUND -> "CTRL_NOT_FOUND" + is CTRL_ERROR -> "CTRL_ERROR $ctrlErr" + is VERSION -> "VERSION" + is ENCRYPT -> "ENCRYPT" + is DECRYPT -> "DECRYPT" + is BLOCK_SIZE -> "BLOCK_SIZE" + is SYNTAX -> "SYNTAX $syntaxErr" + } + @Serializable @SerialName("internal") data class INTERNAL(val internalErr: String): RCErrorType() + @Serializable @SerialName("identity") object IDENTITY: RCErrorType() + @Serializable @SerialName("noLocalAddress") object NO_LOCAL_ADDRESS: RCErrorType() + @Serializable @SerialName("tlsStartFailed") object TLS_START_FAILED: RCErrorType() + @Serializable @SerialName("exception") data class EXCEPTION(val exception: String): RCErrorType() + @Serializable @SerialName("ctrlAuth") object CTRL_AUTH: RCErrorType() + @Serializable @SerialName("ctrlNotFound") object CTRL_NOT_FOUND: RCErrorType() + @Serializable @SerialName("ctrlError") data class CTRL_ERROR(val ctrlErr: String): RCErrorType() + @Serializable @SerialName("version") object VERSION: RCErrorType() + @Serializable @SerialName("encrypt") object ENCRYPT: RCErrorType() + @Serializable @SerialName("decrypt") object DECRYPT: RCErrorType() + @Serializable @SerialName("blockSize") object BLOCK_SIZE: RCErrorType() + @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() +} + @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -4772,22 +4853,21 @@ sealed class RemoteHostError { sealed class RemoteCtrlError { val string: String get() = when (this) { is Inactive -> "inactive" + is BadState -> "badState" is Busy -> "busy" is Timeout -> "timeout" is Disconnected -> "disconnected" - is ConnectionLost -> "connectionLost" - is CertificateExpired -> "certificateExpired" - is CertificateUntrusted -> "certificateUntrusted" - is BadFingerprint -> "badFingerprint" + is BadInvitation -> "badInvitation" + is BadVersion -> "badVersion" } @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("badState") object BadState: RemoteCtrlError() @Serializable @SerialName("busy") object Busy: RemoteCtrlError() @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("connectionLost") class ConnectionLost(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() - @Serializable @SerialName("certificateExpired") class CertificateExpired(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("certificateUntrusted") class CertificateUntrusted(val remoteCtrlId: Long): RemoteCtrlError() - @Serializable @SerialName("badFingerprint") object BadFingerprint: RemoteCtrlError() + @Serializable @SerialName("badInvitation") object BadInvitation: RemoteCtrlError() + @Serializable @SerialName("badVersion") data class BadVersion(val appVersion: String): RemoteCtrlError() + //@Serializable @SerialName("protocolError") data class ProtocolError(val protocolError: RemoteProtocolError): RemoteCtrlError() } enum class NotificationsMode() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index b10a30233f..7d5b1b0196 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -18,6 +18,8 @@ enum class AppPlatform { expect val appPlatform: AppPlatform +expect val deviceName: String + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 91fb4a6e8b..8ce39eea35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -11,10 +11,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @Composable -expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) - -@Composable -fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { +fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 66cac7204a..caf8ec5cb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,6 +26,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.connectMobileDevice import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -42,6 +43,7 @@ fun UserPicker( showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, + useFromDesktopClicked: () -> Unit = {}, settingsClicked: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -203,6 +205,15 @@ fun UserPicker( Divider(Modifier.requiredHeight(1.dp)) } } + if (appPlatform.isAndroid) { + UseFromDesktopPickerItem { + ModalManager.start.showCustomModal { close -> + ConnectDesktopView(close) + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.dp)) + } if (showSettings) { SettingsPickerItem(settingsClicked) } @@ -363,6 +374,16 @@ fun LocalDeviceRow(active: Boolean) { } } +@Composable +private fun UseFromDesktopPickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) + Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 7128d2185c..4ed5ea56b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -52,6 +52,14 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { } } +@Composable +fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { + val density = LocalDensity.current + return remember(id) { + escapedHtmlToAnnotatedString(id.localized().format(args), density) + } +} + // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt new file mode 100644 index 0000000000..d631836dd6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -0,0 +1,472 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionSpacer +import SectionView +import TextIconSpaced +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.switchToLocalSession +import chat.simplex.common.model.ChatModel.connectedToRemote +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectDesktopView(close: () -> Unit) { + val deviceName = remember { controller.appPrefs.deviceNameForRemoteAccess.state } + val closeWithAlert = { + if (!connectedToRemote()) { + close() + } else { + showDisconnectDesktopAlert(close) + } + } + ModalView(close = closeWithAlert) { + ConnectDesktopLayout( + deviceName = deviceName.value!!, + ) + } + val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } + DisposableEffect(Unit) { + withBGApi { + if (!ntfModeService) platform.androidServiceStart() + } + onDispose { + if (!ntfModeService) platform.androidServiceSafeStop() + } + } +} + +@Composable +private fun ConnectDesktopLayout(deviceName: String) { + val sessionAddress = remember { mutableStateOf("") } + val remoteCtrls = remember { mutableStateListOf() } + val session = remember { chatModel.remoteCtrlSession }.value + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + if (session != null) { + when (session.sessionState) { + is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) + is UIRemoteCtrlSessionState.PendingConfirmation -> { + if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { + VerifySession(session, session.sessionState.remoteCtrl_, session.sessionCode!!, remoteCtrls) + } else { + ConnectingDesktop(session, session.sessionState.remoteCtrl_) + LaunchedEffect(Unit) { + verifyDesktopSessionCode(remoteCtrls, session.sessionCode!!) + } + } + } + + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + } + } else { + ConnectDesktop(deviceName, remoteCtrls, sessionAddress) + } + SectionBottomSpacer() + } + DisposableEffect(Unit) { + setDeviceName(deviceName) + updateRemoteCtrls(remoteCtrls) + onDispose { + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } + } + } +} + +@Composable +private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { + AppBarTitle(stringResource(MR.strings.connect_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + ScanDesktopAddressView(sessionAddress) + if (controller.appPrefs.developerTools.get()) { + SectionSpacer() + DesktopAddressView(sessionAddress) + } +} + +@Composable +private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.verify_connection)) + SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + SectionSpacer() + + SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SessionCodeText(sessCode) + } + + SectionSpacer() + + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.confirm_verb)) + } + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + val newDesktop = annotatedStringResource(MR.strings.new_desktop) + val text = remember(rc) { + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + if (rc == null) { + t = t + AnnotatedString(" ") + newDesktop + } + t + } + Text(text) +} + +@Composable +private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { + val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) + val text = remember(session) { + val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + var t = AnnotatedString("v$v") + if (v.text != session.appVersion) { + t = t + AnnotatedString(" ") + thisDeviceVersion + } + t + } + Text(text) +} + +@Composable +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { + AppBarTitle(stringResource(MR.strings.connected_to_desktop)) + SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(rc.deviceViewName) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun SessionCodeText(code: String) { + SelectionContainer { + Text( + code.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } +} + +@Composable +private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { + DeviceNameField(deviceName) { updateDeviceName(it) } + if (remoteCtrls.isNotEmpty()) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + Text(generalGetString(MR.strings.linked_desktops)) + } + } +} + +@Composable +private fun ScanDesktopAddressView(sessionAddress: MutableState) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + sessionAddress.value = text + processDesktopQRCode(sessionAddress, text) + } + } + } +} + +@Composable +private fun DesktopAddressView(sessionAddress: MutableState) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + if (sessionAddress.value.isEmpty()) { + SettingsActionItem( + painterResource(MR.images.ic_content_paste), + stringResource(MR.strings.paste_desktop_address), + disabled = !clipboard.hasText(), + click = { + sessionAddress.value = clipboard.getText()?.text ?: "" + }, + ) + } else { + Row(Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth()) { + val state = remember { + mutableStateOf(TextFieldValue(sessionAddress.value)) + } + DefaultBasicTextField( + Modifier.fillMaxWidth(), + state, + color = MaterialTheme.colors.secondary, + ) { + state.value = it + } + KeyChangeEffect(state.value.text) { + if (state.value.text.isNotEmpty()) { + sessionAddress.value = state.value.text + } + } + } + } + SettingsActionItem( + painterResource(MR.images.ic_wifi), + stringResource(MR.strings.connect_to_desktop), + disabled = sessionAddress.value.isEmpty(), + click = { + connectDesktopAddress(sessionAddress, sessionAddress.value) + }, + ) + } +} + +@Composable +private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.linked_desktops)) + SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + remoteCtrls.forEach { rc -> + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { + RemoteCtrl(rc) + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + unlinkDesktop(remoteCtrls, rc) + showMenu.value = false + } + } + } + + } + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun RemoteCtrl(rc: RemoteCtrlInfo) { + Text(rc.deviceViewName) +} + +private fun setDeviceName(name: String) { + withBGApi { + controller.setLocalDeviceName(name) + } +} + +private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + withBGApi { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) + } + } +} + +private fun processDesktopQRCode(sessionAddress: MutableState, resp: String) { + connectDesktopAddress(sessionAddress, resp) +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + withBGApi { + val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (res.first != null) { + val (rc_, ctrlAppInfo, v) = res.first!! + sessionAddress.value = "" + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = ctrlAppInfo, + appVersion = v, + sessionState = UIRemoteCtrlSessionState.Connecting(remoteCtrl_ = rc_) + ) + } else { + val e = res.second ?: return@withBGApi + when { + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + else -> { + val errMsg = "${e.responseType}: ${e.details}" + Log.e(TAG, "bad response: $errMsg") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) + } + } + } + } +} + +private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList, sessCode: String) { + withBGApi { + val rc = controller.verifyRemoteCtrlSession(sessCode) + if (rc != null) { + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = UIRemoteCtrlSessionState.Connected(remoteCtrl = rc, sessionCode = sessCode)) + } + updateRemoteCtrls(remoteCtrls) + } +} + +@Composable +private fun DisconnectButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.disconnect_remote_host)) + } +} + +private fun disconnectDesktop(close: (() -> Unit)? = null) { + withBGApi { + controller.stopRemoteCtrl() + switchToLocalSession() + close?.invoke() + } +} + +private fun unlinkDesktop(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + withBGApi { + controller.deleteRemoteCtrl(rc.remoteCtrlId) + remoteCtrls.removeAll { it.remoteCtrlId == rc.remoteCtrlId } + } +} + +private fun showUnlinkDesktopAlert(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unlink_desktop_question), + confirmText = generalGetString(MR.strings.unlink_desktop), + destructive = true, + onConfirm = { + unlinkDesktop(remoteCtrls, rc) + } + ) +} + +private fun showDisconnectDesktopAlert(close: (() -> Unit)?) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.disconnect_desktop_question), + text = generalGetString(MR.strings.only_one_device_can_work_at_the_same_time), + confirmText = generalGetString(MR.strings.disconnect_remote_host), + destructive = true, + onConfirm = { disconnectDesktop(close) } + ) +} + +private fun showBadInvitationErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.bad_desktop_address), + ) +} + +private fun showBadVersionAlert(v: String?) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_incompatible_version), + text = generalGetString(MR.strings.desktop_app_version_is_incompatible).format(v ?: "") + ) +} + +private fun showDesktopDisconnectedErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_connection_terminated), + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index d00b9bb67a..0d90e59450 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -141,7 +141,7 @@ fun ConnectMobileLayout( } @Composable -private fun DeviceNameField( +fun DeviceNameField( initialValue: String, onChange: (String) -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 5fa3c41478..2d4e5c86b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectDesktopView import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -158,6 +159,8 @@ fun SettingsLayout( ChatPreferencesItem(showCustomModal, stopped = stopped) if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView(it) }, disabled = stopped, extraPadding = true) + } else { + SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } } SectionDividerSpaced() 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 eb3b9207b7..616e78651d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -954,6 +954,7 @@ CALLS Incognito mode EXPERIMENTAL + Use from desktop Your chat database @@ -1636,6 +1637,7 @@ Verify connection Verify code on mobile This device name + (this device v%s)]]> Connected mobile Connected to mobile Enter this device name… @@ -1644,8 +1646,32 @@ This device Devices New mobile device + Unlink desktop? + Unlink Disconnect + Disconnect desktop? + Only one device can work at the same time Use from desktop in mobile app and scan QR code]]> + Bad desktop address + Incompatible version + Desktop app version %s is not compatible with this app. + Connection terminated + Session code + Connecting to desktop + Connect to desktop + Connected to desktop + Connected desktop + Verify code with desktop + (new)]]> + Linked desktops + Desktop devices + Linked desktop options + Scan QR code from desktop + Desktop address + Verify connections + Discover on network + Paste desktop address + Desktop Coming soon! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 7193fbe2be..92111f162a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -2,11 +2,15 @@ package chat.simplex.common.platform import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi import java.util.* +import chat.simplex.res.MR actual val appPlatform = AppPlatform.DESKTOP +actual val deviceName = generalGetString(MR.strings.desktop_device) + @Suppress("ConstantLocale") val defaultLocale: Locale = Locale.getDefault() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt deleted file mode 100644 index 7ea2ef5368..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.runtime.Composable - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - ScanCodeLayout(verifyCode, close) -}