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>
This commit is contained in:
Stanislav Dmitrenko 2023-11-19 09:07:42 +08:00 committed by GitHub
parent f9e5a56e1a
commit 8f0538e756
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 669 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Preview?>(null) }

View File

@ -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<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
val newRemoteHostPairing = mutableStateOf<Pair<RemoteHostInfo?, RemoteHostSessionState>?>(null)
val remoteCtrlSession = mutableStateOf<RemoteCtrlSession?>(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()
}

View File

@ -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<SomeRemoteCtrl?, CR.ChatCmdError?> {
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<RemoteCtrlInfo>? {
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() {

View File

@ -18,6 +18,8 @@ enum class AppPlatform {
expect val appPlatform: AppPlatform
expect val deviceName: String
val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE
else

View File

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

View File

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

View File

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

View File

@ -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<RemoteCtrlInfo>() }
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<RemoteCtrlInfo>, sessionAddress: MutableState<String>) {
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<RemoteCtrlInfo>) {
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<RemoteCtrlInfo>, 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<String>) {
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<String>) {
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<RemoteCtrlInfo>) {
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<RemoteCtrlInfo>) {
withBGApi {
val res = controller.listRemoteCtrls()
if (res != null) {
remoteCtrls.clear()
remoteCtrls.addAll(res)
}
}
}
private fun processDesktopQRCode(sessionAddress: MutableState<String>, resp: String) {
connectDesktopAddress(sessionAddress, resp)
}
private fun connectDesktopAddress(sessionAddress: MutableState<String>, 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<RemoteCtrlInfo>, 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<RemoteCtrlInfo>, rc: RemoteCtrlInfo) {
withBGApi {
controller.deleteRemoteCtrl(rc.remoteCtrlId)
remoteCtrls.removeAll { it.remoteCtrlId == rc.remoteCtrlId }
}
}
private fun showUnlinkDesktopAlert(remoteCtrls: SnapshotStateList<RemoteCtrlInfo>, 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),
)
}

View File

@ -141,7 +141,7 @@ fun ConnectMobileLayout(
}
@Composable
private fun DeviceNameField(
fun DeviceNameField(
initialValue: String,
onChange: (String) -> Unit
) {

View File

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

View File

@ -954,6 +954,7 @@
<string name="settings_section_title_calls">CALLS</string>
<string name="settings_section_title_incognito">Incognito mode</string>
<string name="settings_section_title_experimenta">EXPERIMENTAL</string>
<string name="settings_section_title_use_from_desktop">Use from desktop</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Your chat database</string>
@ -1636,6 +1637,7 @@
<string name="verify_connection">Verify connection</string>
<string name="verify_code_on_mobile">Verify code on mobile</string>
<string name="this_device_name">This device name</string>
<string name="this_device_version"><![CDATA[<i>(this device v%s)</i>]]></string>
<string name="connected_mobile">Connected mobile</string>
<string name="connected_to_mobile">Connected to mobile</string>
<string name="enter_this_device_name">Enter this device name…</string>
@ -1644,8 +1646,32 @@
<string name="this_device">This device</string>
<string name="devices">Devices</string>
<string name="new_mobile_device">New mobile device</string>
<string name="unlink_desktop_question">Unlink desktop?</string>
<string name="unlink_desktop">Unlink</string>
<string name="disconnect_remote_host">Disconnect</string>
<string name="disconnect_desktop_question">Disconnect desktop?</string>
<string name="only_one_device_can_work_at_the_same_time">Only one device can work at the same time</string>
<string name="open_on_mobile_and_scan_qr_code"><![CDATA[Open <i>Use from desktop</i> in mobile app and scan QR code]]></string>
<string name="bad_desktop_address">Bad desktop address</string>
<string name="desktop_incompatible_version">Incompatible version</string>
<string name="desktop_app_version_is_incompatible">Desktop app version %s is not compatible with this app.</string>
<string name="desktop_connection_terminated">Connection terminated</string>
<string name="session_code">Session code</string>
<string name="connecting_to_desktop">Connecting to desktop</string>
<string name="connect_to_desktop">Connect to desktop</string>
<string name="connected_to_desktop">Connected to desktop</string>
<string name="connected_desktop">Connected desktop</string>
<string name="verify_code_with_desktop">Verify code with desktop</string>
<string name="new_desktop"><![CDATA[<i>(new)</i>]]></string>
<string name="linked_desktops">Linked desktops</string>
<string name="desktop_devices">Desktop devices</string>
<string name="linked_desktop_options">Linked desktop options</string>
<string name="scan_qr_code_from_desktop">Scan QR code from desktop</string>
<string name="desktop_address">Desktop address</string>
<string name="verify_connections">Verify connections</string>
<string name="discover_on_network">Discover on network</string>
<string name="paste_desktop_address">Paste desktop address</string>
<string name="desktop_device">Desktop</string>
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>

View File

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

View File

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