android, desktop: connect remote desktop via multicast (#3442)

* android, desktop: connect remote desktop via multicast

* changes

* string

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko 2023-11-24 05:00:11 +08:00 committed by GitHub
parent f7903c5c83
commit b2dbb558f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 200 additions and 36 deletions

View File

@ -2921,7 +2921,7 @@ enum class NotificationPreviewMode {
}
data class RemoteCtrlSession(
val ctrlAppInfo: CtrlAppInfo,
val ctrlAppInfo: CtrlAppInfo?,
val appVersion: String,
val sessionState: UIRemoteCtrlSessionState
) {
@ -2939,6 +2939,7 @@ data class RemoteCtrlSession(
@Serializable
sealed class RemoteCtrlSessionState {
@Serializable @SerialName("starting") object Starting: RemoteCtrlSessionState()
@Serializable @SerialName("searching") object Searching: RemoteCtrlSessionState()
@Serializable @SerialName("connecting") object Connecting: RemoteCtrlSessionState()
@Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val sessionCode: String): RemoteCtrlSessionState()
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState()
@ -2946,6 +2947,8 @@ sealed class RemoteCtrlSessionState {
sealed class UIRemoteCtrlSessionState {
@Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState()
@Serializable @SerialName("searching") object Searching: UIRemoteCtrlSessionState()
@Serializable @SerialName("found") data class Found(val remoteCtrl: RemoteCtrlInfo, val compatible: Boolean): 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

@ -170,6 +170,7 @@ class AppPreferences {
val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false)
val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false)
val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true)
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
private fun mkIntPreference(prefName: String, default: Int) =
@ -314,6 +315,7 @@ class AppPreferences {
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_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
}
}
@ -1432,14 +1434,25 @@ object ChatController {
suspend fun connectRemoteCtrl(desktopAddress: String): Pair<SomeRemoteCtrl?, CR.ChatCmdError?> {
val r = sendCmd(null, 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}")
return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null
else if (r is CR.ChatCmdError) null to r
else {
apiErrorAlert("connectRemoteCtrl", generalGetString(MR.strings.error_alert_title), r)
null to null
}
}
suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl())
suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId))
suspend fun confirmRemoteCtrl(rcId: Long): Pair<SomeRemoteCtrl?, CR.ChatCmdError?> {
val r = sendCmd(null, CC.ConfirmRemoteCtrl(remoteCtrlId = rcId))
return if (r is CR.RemoteCtrlConnecting) SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null
else if (r is CR.ChatCmdError) null to r
else {
apiErrorAlert("confirmRemoteCtrl", generalGetString(MR.strings.error_alert_title), r)
null to null
}
}
suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? {
val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode))
@ -1857,8 +1870,15 @@ object ChatController {
}
}
is CR.RemoteCtrlFound -> {
// TODO multicast
Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}")
val sess = chatModel.remoteCtrlSession.value
if (sess != null && sess.sessionState is UIRemoteCtrlSessionState.Searching) {
val state = UIRemoteCtrlSessionState.Found(remoteCtrl = r.remoteCtrl, compatible = r.compatible)
chatModel.remoteCtrlSession.value = RemoteCtrlSession(
ctrlAppInfo = r.ctrlAppInfo_,
appVersion = r.appVersion,
sessionState = state
)
}
}
is CR.RemoteCtrlSessionCode -> {
val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode)
@ -1870,7 +1890,13 @@ object ChatController {
chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state)
}
is CR.RemoteCtrlStopped -> {
switchToLocalSession()
val sess = chatModel.remoteCtrlSession.value
if (sess != null) {
chatModel.remoteCtrlSession.value = null
if (sess.sessionState is UIRemoteCtrlSessionState.Connected) {
switchToLocalSession()
}
}
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
@ -3782,7 +3808,7 @@ sealed class CR {
@Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR()
// remote events (mobile)
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
@Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR()
@Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo, val ctrlAppInfo_: CtrlAppInfo?, val appVersion: String, val compatible: Boolean): CR()
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR()
@Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR()
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR()
@ -4080,7 +4106,11 @@ sealed class CR {
is RemoteHostStopped -> "remote host ID: $remoteHostId_"
is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource)
is RemoteCtrlList -> json.encodeToString(remoteCtrls)
is RemoteCtrlFound -> json.encodeToString(remoteCtrl)
is RemoteCtrlFound -> "remote ctrl: " + json.encodeToString(remoteCtrl) +
"\nctrlAppInfo: " +
(if (ctrlAppInfo_ == null) "null" else json.encodeToString(ctrlAppInfo_)) +
"\nappVersion: $appVersion" +
"\ncompatible: $compatible"
is RemoteCtrlConnecting ->
"remote ctrl: " +
(if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) +

View File

@ -125,7 +125,7 @@ fun DefaultConfigurableTextField(
keyboardType: KeyboardType = KeyboardType.Text,
dependsOn: State<Any?>? = null,
) {
var valid by remember { mutableStateOf(validKey(state.value.text)) }
var valid by remember { mutableStateOf(isValid(state.value.text)) }
var showKey by remember { mutableStateOf(false) }
val icon = if (valid) {
if (showKey) painterResource(MR.images.ic_visibility_off_filled) else painterResource(MR.images.ic_visibility_filled)

View File

@ -21,6 +21,7 @@ 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.font.FontStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -37,6 +38,7 @@ 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.ImageResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@ -69,15 +71,21 @@ fun ConnectDesktopView(close: () -> Unit) {
@Composable
private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) {
val showConnectScreen = remember { mutableStateOf(true) }
val sessionAddress = remember { mutableStateOf("") }
val remoteCtrls = remember { mutableStateListOf<RemoteCtrlInfo>() }
val session = remember { chatModel.remoteCtrlSession }.value
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
if (session != null) {
val discovery = if (session == null) null else session.sessionState is UIRemoteCtrlSessionState.Searching
if (discovery == true || (discovery == null && !showConnectScreen.value)) {
SearchingDesktop(deviceName, remoteCtrls)
} else if (session != null) {
when (session.sessionState) {
is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null)
is UIRemoteCtrlSessionState.Searching -> SearchingDesktop(deviceName, remoteCtrls)
is UIRemoteCtrlSessionState.Found -> FoundDesktop(session, session.sessionState.remoteCtrl, session.sessionState.compatible, remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }, deviceName, remoteCtrls, sessionAddress)
is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_)
is UIRemoteCtrlSessionState.PendingConfirmation -> {
if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) {
@ -97,11 +105,21 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) {
}
SectionBottomSpacer()
}
DisposableEffect(Unit) {
LaunchedEffect(Unit) {
setDeviceName(deviceName)
updateRemoteCtrls(remoteCtrls)
val useMulticast = useMulticast(remoteCtrls)
showConnectScreen.value = !useMulticast
if (chatModel.remoteCtrlSession.value != null) {
disconnectDesktop()
} else if (useMulticast) {
findKnownDesktop(showConnectScreen)
}
}
DisposableEffect(Unit) {
onDispose {
if (chatModel.remoteCtrlSession.value != null) {
showConnectScreen.value = false
disconnectDesktop()
}
}
@ -146,7 +164,75 @@ private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) {
SectionSpacer()
SectionView {
DisconnectButton(::disconnectDesktop)
DisconnectButton(onClick = ::disconnectDesktop)
}
}
@Composable
private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
AppBarTitle(stringResource(MR.strings.connecting_to_desktop))
SectionView(stringResource(MR.strings.this_device_name).uppercase()) {
DevicesView(deviceName, remoteCtrls) {
if (it != "") {
setDeviceName(it)
controller.appPrefs.deviceNameForRemoteAccess.set(it)
}
}
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic)
}
SectionSpacer()
DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop)
}
@Composable
private fun FoundDesktop(
session: RemoteCtrlSession,
rc: RemoteCtrlInfo,
compatible: Boolean,
connectRemoteViaMulticastAuto: State<Boolean>,
deviceName: String,
remoteCtrls: SnapshotStateList<RemoteCtrlInfo>,
sessionAddress: MutableState<String>,
) {
AppBarTitle(stringResource(MR.strings.found_desktop))
SectionView(stringResource(MR.strings.this_device_name).uppercase()) {
DevicesView(deviceName, remoteCtrls) {
if (it != "") {
setDeviceName(it)
controller.appPrefs.deviceNameForRemoteAccess.set(it)
}
}
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.found_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
if (!compatible) {
Text(stringResource(MR.strings.not_compatible), color = MaterialTheme.colors.error)
}
}
SectionSpacer()
if (compatible) {
SectionItemView({ confirmKnownDesktop(sessionAddress, rc) }) {
Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.connect_button), tint = MaterialTheme.colors.secondary)
TextIconSpaced(false)
Text(generalGetString(MR.strings.connect_button))
}
}
if (!compatible || !connectRemoteViaMulticastAuto.value) {
DisconnectButton(stringResource(MR.strings.cancel_verb), onClick = ::disconnectDesktop)
}
if (compatible && connectRemoteViaMulticastAuto.value) {
LaunchedEffect(Unit) {
confirmKnownDesktop(sessionAddress, rc)
}
}
}
@ -174,7 +260,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
}
SectionView {
DisconnectButton(::disconnectDesktop)
DisconnectButton(onClick = ::disconnectDesktop)
}
}
@ -182,7 +268,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC
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)
var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo?.deviceName ?: "")
if (rc == null) {
t = t + AnnotatedString(" ") + newDesktop
}
@ -195,7 +281,7 @@ private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?)
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)
val v = AnnotatedString(session.ctrlAppInfo?.appVersionRange?.maxVersion ?: "")
var t = AnnotatedString("v$v")
if (v.text != session.appVersion) {
t = t + AnnotatedString(" ") + thisDeviceVersion
@ -243,7 +329,8 @@ private fun SessionCodeText(code: String) {
private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList<RemoteCtrlInfo>, updateDeviceName: (String) -> Unit) {
DeviceNameField(deviceName) { updateDeviceName(it) }
if (remoteCtrls.isNotEmpty()) {
SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) {
SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) }
}) {
Text(generalGetString(MR.strings.linked_desktops))
}
}
@ -336,8 +423,13 @@ private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
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)
PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) {
controller.appPrefs.connectRemoteViaMulticast.set(it)
}
if (remember { controller.appPrefs.connectRemoteViaMulticast.state }.value) {
PreferenceToggle(stringResource(MR.strings.multicast_connect_automatically), remember { controller.appPrefs.connectRemoteViaMulticastAuto.state }.value) {
controller.appPrefs.connectRemoteViaMulticastAuto.set(it)
}
}
}
SectionBottomSpacer()
@ -355,13 +447,11 @@ private fun setDeviceName(name: String) {
}
}
private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
withBGApi {
val res = controller.listRemoteCtrls()
if (res != null) {
remoteCtrls.clear()
remoteCtrls.addAll(res)
}
private suspend fun updateRemoteCtrls(remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
val res = controller.listRemoteCtrls()
if (res != null) {
remoteCtrls.clear()
remoteCtrls.addAll(res)
}
}
@ -369,9 +459,34 @@ private fun processDesktopQRCode(sessionAddress: MutableState<String>, resp: Str
connectDesktopAddress(sessionAddress, resp)
}
private fun connectDesktopAddress(sessionAddress: MutableState<String>, addr: String) {
private fun findKnownDesktop(showConnectScreen: MutableState<Boolean>) {
withBGApi {
val res = controller.connectRemoteCtrl(desktopAddress = addr)
if (controller.findKnownRemoteCtrl()) {
chatModel.remoteCtrlSession.value = RemoteCtrlSession(
ctrlAppInfo = null,
appVersion = "",
sessionState = UIRemoteCtrlSessionState.Searching
)
showConnectScreen.value = true
}
}
}
private fun confirmKnownDesktop(sessionAddress: MutableState<String>, rc: RemoteCtrlInfo) {
connectDesktop(sessionAddress) {
controller.confirmRemoteCtrl(rc.remoteCtrlId)
}
}
private fun connectDesktopAddress(sessionAddress: MutableState<String>, addr: String) {
connectDesktop(sessionAddress) {
controller.connectRemoteCtrl(addr)
}
}
private fun connectDesktop(sessionAddress: MutableState<String>, connect: suspend () -> Pair<SomeRemoteCtrl?, CR.ChatCmdError?>) {
withBGApi {
val res = connect()
if (res.first != null) {
val (rc_, ctrlAppInfo, v) = res.first!!
sessionAddress.value = ""
@ -409,18 +524,25 @@ private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList<RemoteCtrlIn
}
@Composable
private fun DisconnectButton(onClick: () -> Unit) {
private fun DisconnectButton(label: String = generalGetString(MR.strings.disconnect_remote_host), icon: ImageResource = MR.images.ic_close, onClick: () -> Unit) {
SectionItemView(onClick) {
Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary)
Icon(painterResource(icon), label, tint = MaterialTheme.colors.secondary)
TextIconSpaced(false)
Text(generalGetString(MR.strings.disconnect_remote_host))
Text(label)
}
}
private fun useMulticast(remoteCtrls: List<RemoteCtrlInfo>): Boolean =
controller.appPrefs.connectRemoteViaMulticast.get() && remoteCtrls.isNotEmpty()
private fun disconnectDesktop(close: (() -> Unit)? = null) {
withBGApi {
controller.stopRemoteCtrl()
switchToLocalSession()
if (chatModel.remoteCtrlSession.value?.sessionState is UIRemoteCtrlSessionState.Connected) {
switchToLocalSession()
} else {
chatModel.remoteCtrlSession.value = null
}
close?.invoke()
}
}

View File

@ -30,6 +30,7 @@ import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.common.views.usersettings.SettingsActionItemWithContent
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@ -90,6 +91,9 @@ fun ConnectMobileLayout(
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { controller.appPrefs.offerRemoteMulticast.state }.value) {
controller.appPrefs.offerRemoteMulticast.set(it)
}
SectionDividerSpaced(maxBottomPadding = false)
}
SectionView(stringResource(MR.strings.devices).uppercase()) {
@ -266,7 +270,7 @@ private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
}
DisposableEffect(Unit) {
withBGApi {
val r = chatModel.controller.startRemoteHost(null)
val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get())
if (r != null) {
connecting.value = true
invitation.value = r.second
@ -309,7 +313,7 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState
)
var remoteHostId by rememberSaveable { mutableStateOf<Long?>(null) }
LaunchedEffect(Unit) {
val r = chatModel.controller.startRemoteHost(rh.remoteHostId)
val r = chatModel.controller.startRemoteHost(rh.remoteHostId, controller.appPrefs.offerRemoteMulticast.get())
if (r != null) {
val (rh_, inv) = r
connecting.value = true

View File

@ -1670,6 +1670,8 @@
<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="waiting_for_desktop">Waiting for desktop…</string>
<string name="found_desktop">Found 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>
@ -1681,9 +1683,12 @@
<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="discover_on_network">Discover via local network</string>
<string name="multicast_discoverable_via_local_network">Discoverable via local network</string>
<string name="multicast_connect_automatically">Connect automatically</string>
<string name="paste_desktop_address">Paste desktop address</string>
<string name="desktop_device">Desktop</string>
<string name="not_compatible">Not compatible!</string>
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>