desktop, ios: remote desktop/mobile connection (#3223)

* ui: remote desktop/mobile connection (WIP)

* add startRemoteCtrl and capability (does not work)

* re-add view

* update core library

* iOS connects to CLI

* ios: mobile ui

* multiplatform types

* update lib

* iOS and desktop connected

* fix controllers list on mobile

* remove iOS 16 paste button

* update device name

* connect existing device

* proposed model

* missing function names in exports

* unused

* remote host picker

* update type

* update lib, keep iOS session alive

* better UI

* update network statuses on switching remote/local hosts

* changes

* ios: prevent dismissing sheet/back when session is connected

* changes

* ios: fix back button asking to disconnect when not connected

* iOS: update type

* picker and session code

* multiplatform: update type

* menu fix

* ios: better ux

* desktop: better ux

* ios: options etc

* UI

* desktop: fix RemoteHostStopped event

* ios: open Use from desktop via picker

* desktop: "new mobile device"

* ios: load remote controllers synchronously, update on connect, fix alerts

* titles

* changes

* more changes to ui

* more and more changes in ui

* padding

* ios: show desktop version, handle errors

* fix stopped event

* refresh hosts always

* radical change

* optimization

* change

* ios: stop in progress session when closing window

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Evgeny Poberezkin
2023-11-16 16:53:44 +00:00
committed by GitHub
parent c31ae39617
commit 0322b9708b
30 changed files with 1569 additions and 254 deletions
@@ -41,6 +41,7 @@ typedef long* chat_ctrl;
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
@@ -86,6 +87,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclas
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd((void*)controller, rhId, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
@@ -16,6 +16,7 @@ typedef long* chat_ctrl;
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_send_remote_cmd(chat_ctrl ctrl, const int rhId, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
@@ -98,6 +99,14 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz,
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmd(JNIEnv *env, jclass clazz, jlong controller, jint rhId, jstring msg) {
const char *_msg = encode_to_utf8_chars(env, msg);
jstring res = decode_to_utf8_string(env, chat_send_remote_cmd((void*)controller, rhId, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) {
return decode_to_utf8_string(env, chat_recv_msg((void*)controller));
@@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.*
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState,
val switchingUsers: MutableState<Boolean>
val switchingUsersAndHosts: MutableState<Boolean>
)
@Composable
@@ -121,8 +121,8 @@ fun MainScreen() {
showAdvertiseLAAlert = true
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val scaffoldState = rememberScaffoldState()
val switchingUsers = rememberSaveable { mutableStateOf(false) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) }
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
} else {
@@ -298,7 +298,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
Box(
@@ -312,8 +312,9 @@ fun DesktopScreen(settingsState: SettingsViewState) {
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState, switchingUsers) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
ModalManager.fullscreen.showInView()
}
@@ -106,6 +106,11 @@ object ChatModel {
var updatingChatsMutex: Mutex = Mutex()
// remote controller
val remoteHosts = mutableStateListOf<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
val newRemoteHostPairing = mutableStateOf<Pair<RemoteHostInfo?, RemoteHostSessionState>?>(null)
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
} else {
@@ -2841,3 +2846,17 @@ enum class NotificationPreviewMode {
val default: NotificationPreviewMode = MESSAGE
}
}
data class RemoteCtrlSession(
val ctrlAppInfo: CtrlAppInfo,
val appVersion: String,
val sessionState: RemoteCtrlSessionState
)
@Serializable
sealed class RemoteCtrlSessionState {
@Serializable @SerialName("starting") object Starting: 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()
}
@@ -345,11 +345,6 @@ object ChatController {
val users = listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
val remoteHosts = listRemoteHosts()
if (remoteHosts != null) {
chatModel.remoteHosts.clear()
chatModel.remoteHosts.addAll(remoteHosts)
}
if (justStarted) {
chatModel.currentUser.value = user
chatModel.userCreated.value = true
@@ -357,6 +352,7 @@ object ChatController {
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!)
Log.d(TAG, "startChat: started")
} else {
updatingChatsMutex.withLock {
@@ -429,7 +425,8 @@ object ChatController {
val c = cmd.cmdString
chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
val json = chatSendCmd(ctrl, c)
val rhId = chatModel.currentRemoteHost.value?.remoteHostId?.toInt() ?: -1
val json = if (rhId == -1) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId, c)
val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
@@ -1174,10 +1171,10 @@ object ChatController {
}
}
suspend fun cancelFile(user: User, fileId: Long) {
suspend fun cancelFile(rhId: Long?, user: User, fileId: Long) {
val chatItem = apiCancelFile(fileId)
if (chatItem != null) {
chatItemSimpleUpdate(user, chatItem)
chatItemSimpleUpdate(rhId, user, chatItem)
cleanupFile(chatItem)
}
}
@@ -1371,46 +1368,77 @@ object ChatController {
suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(CC.SetLocalDeviceName(displayName))
suspend fun createRemoteHost(): RemoteHostInfo? {
val r = sendCmd(CC.CreateRemoteHost())
if (r is CR.RemoteHostCreated) return r.remoteHost
apiErrorAlert("createRemoteHost", generalGetString(MR.strings.error), r)
return null
}
suspend fun listRemoteHosts(): List<RemoteHostInfo>? {
val r = sendCmd(CC.ListRemoteHosts())
if (r is CR.RemoteHostList) return r.remoteHosts
apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error), r)
apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r)
return null
}
suspend fun startRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StartRemoteHost(rhId))
suspend fun reloadRemoteHosts() {
val hosts = listRemoteHosts() ?: return
chatModel.remoteHosts.clear()
chatModel.remoteHosts.addAll(hosts)
}
suspend fun registerRemoteCtrl(oob: RemoteCtrlOOB): RemoteCtrlInfo? {
val r = sendCmd(CC.RegisterRemoteCtrl(oob))
if (r is CR.RemoteCtrlRegistered) return r.remoteCtrl
apiErrorAlert("registerRemoteCtrl", generalGetString(MR.strings.error), r)
suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair<RemoteHostInfo?, String>? {
val r = sendCmd(CC.StartRemoteHost(rhId, multicast))
if (r is CR.RemoteHostStarted) return r.remoteHost_ to r.invitation
apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r)
return null
}
suspend fun switchRemoteHost (rhId: Long?): RemoteHostInfo? {
val r = sendCmd(CC.SwitchRemoteHost(rhId))
if (r is CR.CurrentRemoteHost) return r.remoteHost_
apiErrorAlert("switchRemoteHost", generalGetString(MR.strings.error_alert_title), r)
return null
}
suspend fun stopRemoteHost(rhId: Long?): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId))
fun stopRemoteHostAndReloadHosts(h: RemoteHostInfo, switchToLocal: Boolean) {
withBGApi {
stopRemoteHost(h.remoteHostId)
if (switchToLocal) {
switchUIRemoteHost(null)
} else {
reloadRemoteHosts()
}
}
}
suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId))
suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? {
val r = sendCmd(CC.StoreRemoteFile(rhId, storeEncrypted, localPath))
if (r is CR.RemoteFileStored) return r.remoteFileSource
apiErrorAlert("storeRemoteFile", generalGetString(MR.strings.error_alert_title), r)
return null
}
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 findKnownRemoteCtrl(): Boolean = sendCommandOkResp(CC.FindKnownRemoteCtrl())
suspend fun confirmRemoteCtrl(rhId: Long): Boolean = sendCommandOkResp(CC.ConfirmRemoteCtrl(rhId))
suspend fun verifyRemoteCtrlSession(sessionCode: String): Boolean = sendCommandOkResp(CC.VerifyRemoteCtrlSession(sessionCode))
suspend fun listRemoteCtrls(): List<RemoteCtrlInfo>? {
val r = sendCmd(CC.ListRemoteCtrls())
if (r is CR.RemoteCtrlList) return r.remoteCtrls
apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error), r)
apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r)
return null
}
suspend fun stopRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.StopRemoteHost(rhId))
suspend fun deleteRemoteHost(rhId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteHost(rhId))
suspend fun startRemoteCtrl(): Boolean = sendCommandOkResp(CC.StartRemoteCtrl())
suspend fun acceptRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.AcceptRemoteCtrl(rcId))
suspend fun rejectRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.RejectRemoteCtrl(rcId))
suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(CC.StopRemoteCtrl())
suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(CC.DeleteRemoteCtrl(rcId))
@@ -1465,6 +1493,8 @@ object ChatController {
private suspend fun processReceivedMsg(apiResp: APIResponse) {
lastMsgReceivedTimestamp = System.currentTimeMillis()
val r = apiResp.resp
val rhId = apiResp.remoteHostId
fun active(user: UserLike): Boolean = activeUser(rhId, user)
chatModel.addTerminalItem(TerminalItem.resp(r))
when (r) {
is CR.NewContactConnection -> {
@@ -1577,7 +1607,7 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) }
withApi { receiveFile(rhId, r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1591,7 +1621,7 @@ object ChatController {
}
}
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.ChatItemReaction -> {
if (active(r.user)) {
chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
@@ -1703,37 +1733,37 @@ object ChatController {
chatModel.updateContact(r.contact)
}
is CR.RcvFileStart ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.RcvFileComplete ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.RcvFileSndCancelled -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.RcvFileProgressXFTP ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.RcvFileError -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.SndFileStart ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.SndFileComplete -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupDirectFile(r.chatItem)
}
is CR.SndFileRcvCancelled -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupDirectFile(r.chatItem)
}
is CR.SndFileProgressXFTP ->
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.SndFileCompleteXFTP -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.SndFileError -> {
chatItemSimpleUpdate(r.user, r.chatItem)
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.CallInvitation -> {
@@ -1789,12 +1819,18 @@ object ChatController {
chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats)
is CR.GroupMemberRatchetSync ->
chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
is CR.RemoteHostSessionCode -> {
chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode)
}
is CR.RemoteHostConnected -> {
// update
chatModel.connectingRemoteHost.value = r.remoteHost
// TODO needs to update it instead in sessions
chatModel.currentRemoteHost.value = r.remoteHost
switchUIRemoteHost(r.remoteHost.remoteHostId)
}
is CR.RemoteHostStopped -> {
//
chatModel.currentRemoteHost.value = null
chatModel.newRemoteHostPairing.value = null
switchUIRemoteHost(null)
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
@@ -1819,7 +1855,8 @@ object ChatController {
}
}
private fun active(user: UserLike): Boolean = user.userId == chatModel.currentUser.value?.userId
private fun activeUser(rhId: Long?, user: UserLike): Boolean =
rhId == chatModel.currentRemoteHost.value?.remoteHostId && user.userId == chatModel.currentUser.value?.userId
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
val call = chatModel.activeCall.value
@@ -1830,10 +1867,10 @@ object ChatController {
}
}
suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) {
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto)
if (chatItem != null) {
chatItemSimpleUpdate(user, chatItem)
chatItemSimpleUpdate(rhId, user, chatItem)
}
}
@@ -1844,11 +1881,11 @@ object ChatController {
}
}
private suspend fun chatItemSimpleUpdate(user: UserLike, aChatItem: AChatItem) {
private suspend fun chatItemSimpleUpdate(rhId: Long?, user: UserLike, aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) }
if (!active(user)) {
if (!activeUser(rhId, user)) {
notify()
} else if (chatModel.upsertChatItem(cInfo, cItem)) {
notify()
@@ -1876,6 +1913,25 @@ object ChatController {
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
}
suspend fun switchUIRemoteHost(rhId: Long?) {
chatModel.chatId.value = null
chatModel.currentRemoteHost.value = switchRemoteHost(rhId)
reloadRemoteHosts()
val user = apiGetActiveUser()
val users = listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.currentUser.value = user
chatModel.userCreated.value = true
val statuses = apiGetNetworkStatuses()
if (statuses != null) {
chatModel.networkStatuses.clear()
val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap()
chatModel.networkStatuses.putAll(ss)
}
getUserChatData()
}
fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0)
}
@@ -2059,19 +2115,23 @@ sealed class CC {
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
// Remote control
class SetLocalDeviceName(val displayName: String): CC()
class CreateRemoteHost(): CC()
class ListRemoteHosts(): CC()
class StartRemoteHost(val remoteHostId: Long): CC()
class StopRemoteHost(val remoteHostId: Long): CC()
class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC()
class SwitchRemoteHost (val remoteHostId: Long?): CC()
class StopRemoteHost(val remoteHostKey: Long?): CC()
class DeleteRemoteHost(val remoteHostId: Long): CC()
class StartRemoteCtrl(): CC()
class RegisterRemoteCtrl(val remoteCtrlOOB: RemoteCtrlOOB): CC()
class StoreRemoteFile(val remoteHostId: Long, val storeEncrypted: Boolean?, val localPath: String): CC()
class GetRemoteFile(val remoteHostId: Long, val file: RemoteFile): CC()
class ConnectRemoteCtrl(val xrcpInvitation: String): CC()
class FindKnownRemoteCtrl(): CC()
class ConfirmRemoteCtrl(val remoteCtrlId: Long): CC()
class VerifyRemoteCtrlSession(val sessionCode: String): CC()
class ListRemoteCtrls(): CC()
class AcceptRemoteCtrl(val remoteCtrlId: Long): CC()
class RejectRemoteCtrl(val remoteCtrlId: Long): CC()
class StopRemoteCtrl(): CC()
class DeleteRemoteCtrl(val remoteCtrlId: Long): CC()
// misc
class ShowVersion(): CC()
val cmdString: String get() = when (this) {
@@ -2192,15 +2252,20 @@ sealed class CC {
(if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
is SetLocalDeviceName -> "/set device name $displayName"
is CreateRemoteHost -> "/create remote host"
is ListRemoteHosts -> "/list remote hosts"
is StartRemoteHost -> "/start remote host $remoteHostId"
is StopRemoteHost -> "/stop remote host $remoteHostId"
is StartRemoteHost -> "/start remote host " + if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}"
is SwitchRemoteHost -> "/switch remote host " + if (remoteHostId == null) "local" else "$remoteHostId"
is StopRemoteHost -> "/stop remote host " + if (remoteHostKey == null) "new" else "$remoteHostKey"
is DeleteRemoteHost -> "/delete remote host $remoteHostId"
is StartRemoteCtrl -> "/start remote ctrl"
is RegisterRemoteCtrl -> "/register remote ctrl ${remoteCtrlOOB.fingerprint}"
is AcceptRemoteCtrl -> "/accept remote ctrl $remoteCtrlId"
is RejectRemoteCtrl -> "/reject remote ctrl $remoteCtrlId"
is StoreRemoteFile ->
"/store remote file $remoteHostId " +
(if (storeEncrypted == null) "" else " encrypt=${onOff(storeEncrypted)}") +
localPath
is GetRemoteFile -> "/get remote file $remoteHostId ${json.encodeToString(file)}"
is ConnectRemoteCtrl -> "/connect remote ctrl $xrcpInvitation"
is FindKnownRemoteCtrl -> "/find remote ctrl"
is ConfirmRemoteCtrl -> "/confirm remote ctrl $remoteCtrlId"
is VerifyRemoteCtrlSession -> "/verify remote ctrl $sessionCode"
is ListRemoteCtrls -> "/list remote ctrls"
is StopRemoteCtrl -> "/stop remote ctrl"
is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId"
@@ -2306,16 +2371,18 @@ sealed class CC {
is ReceiveFile -> "receiveFile"
is CancelFile -> "cancelFile"
is SetLocalDeviceName -> "setLocalDeviceName"
is CreateRemoteHost -> "createRemoteHost"
is ListRemoteHosts -> "listRemoteHosts"
is StartRemoteHost -> "startRemoteHost"
is SwitchRemoteHost -> "switchRemoteHost"
is StopRemoteHost -> "stopRemoteHost"
is DeleteRemoteHost -> "deleteRemoteHost"
is StartRemoteCtrl -> "startRemoteCtrl"
is RegisterRemoteCtrl -> "registerRemoteCtrl"
is StoreRemoteFile -> "storeRemoteFile"
is GetRemoteFile -> "getRemoteFile"
is ConnectRemoteCtrl -> "connectRemoteCtrl"
is FindKnownRemoteCtrl -> "FindKnownRemoteCtrl"
is ConfirmRemoteCtrl -> "confirmRemoteCtrl"
is VerifyRemoteCtrlSession -> "verifyRemoteCtrlSession"
is ListRemoteCtrls -> "listRemoteCtrls"
is AcceptRemoteCtrl -> "acceptRemoteCtrl"
is RejectRemoteCtrl -> "rejectRemoteCtrl"
is StopRemoteCtrl -> "stopRemoteCtrl"
is DeleteRemoteCtrl -> "deleteRemoteCtrl"
is ShowVersion -> "showVersion"
@@ -3388,27 +3455,34 @@ data class RemoteCtrl (
val accepted: Boolean?
)
@Serializable
data class RemoteCtrlOOB (
val fingerprint: String,
val displayName: String
)
@Serializable
data class RemoteCtrlInfo (
val remoteCtrlId: Long,
val displayName: String,
val sessionActive: Boolean
val ctrlDeviceName: String,
val sessionState: RemoteCtrlSessionState?
)
@Serializable
data class RemoteHostInfo (
data class RemoteHostInfo(
val remoteHostId: Long,
val hostDeviceName: String,
val storePath: String,
val displayName: String,
val remoteCtrlOOB: RemoteCtrlOOB,
val sessionActive: Boolean
)
val sessionState: RemoteHostSessionState?
) {
val activeHost: Boolean
@Composable get() = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId
fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId
}
@Serializable
sealed class RemoteHostSessionState {
@Serializable @SerialName("starting") object Starting: RemoteHostSessionState()
@Serializable @SerialName("connecting") class Connecting(val invitation: String): RemoteHostSessionState()
@Serializable @SerialName("pendingConfirmation") class PendingConfirmation(val sessionCode: String): RemoteHostSessionState()
@Serializable @SerialName("confirmed") data class Confirmed(val sessionCode: String): RemoteHostSessionState()
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState()
}
val json = Json {
prettyPrint = true
@@ -3621,16 +3695,19 @@ sealed class CR {
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
// remote events (desktop)
@Serializable @SerialName("remoteHostCreated") class RemoteHostCreated(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
@Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR()
@Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String): CR()
@Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR()
@Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId: Long): CR()
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): 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("remoteCtrlRegistered") class RemoteCtrlRegistered(val remoteCtrl: RemoteCtrlInfo): CR()
@Serializable @SerialName("remoteCtrlAnnounce") class RemoteCtrlAnnounce(val fingerprint: String): CR()
@Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(val remoteCtrl: RemoteCtrlInfo): CR()
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl: RemoteCtrlInfo): 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()
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): CR()
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
@@ -3767,15 +3844,18 @@ sealed class CR {
is CallEnded -> "callEnded"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is RemoteHostCreated -> "remoteHostCreated"
is RemoteHostList -> "remoteHostList"
is CurrentRemoteHost -> "currentRemoteHost"
is RemoteHostStarted -> "remoteHostStarted"
is RemoteHostSessionCode -> "remoteHostSessionCode"
is NewRemoteHost -> "newRemoteHost"
is RemoteHostConnected -> "remoteHostConnected"
is RemoteHostStopped -> "remoteHostStopped"
is RemoteFileStored -> "remoteFileStored"
is RemoteCtrlList -> "remoteCtrlList"
is RemoteCtrlRegistered -> "remoteCtrlRegistered"
is RemoteCtrlAnnounce -> "remoteCtrlAnnounce"
is RemoteCtrlFound -> "remoteCtrlFound"
is RemoteCtrlConnecting -> "remoteCtrlConnecting"
is RemoteCtrlSessionCode -> "remoteCtrlSessionCode"
is RemoteCtrlConnected -> "remoteCtrlConnected"
is RemoteCtrlStopped -> "remoteCtrlStopped"
is VersionInfo -> "versionInfo"
@@ -3912,15 +3992,29 @@ sealed class CR {
is CallEnded -> withUser(user, "contact: ${contact.id}")
is NewContactConnection -> withUser(user, json.encodeToString(connection))
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
is RemoteHostCreated -> json.encodeToString(remoteHost)
// remote events (mobile)
is RemoteHostList -> json.encodeToString(remoteHosts)
is CurrentRemoteHost -> if (remoteHost_ == null) "local" else json.encodeToString(remoteHost_)
is RemoteHostStarted -> if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_)
is RemoteHostSessionCode ->
"remote host: " +
(if (remoteHost_ == null) "new" else json.encodeToString(remoteHost_)) +
"\nsession code: $sessionCode"
is NewRemoteHost -> json.encodeToString(remoteHost)
is RemoteHostConnected -> json.encodeToString(remoteHost)
is RemoteHostStopped -> "remote host ID: $remoteHostId"
is RemoteHostStopped -> "remote host ID: $remoteHostId_"
is RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource)
is RemoteCtrlList -> json.encodeToString(remoteCtrls)
is RemoteCtrlRegistered -> json.encodeToString(remoteCtrl)
is RemoteCtrlAnnounce -> "fingerprint: $fingerprint"
is RemoteCtrlFound -> json.encodeToString(remoteCtrl)
is RemoteCtrlConnecting -> json.encodeToString(remoteCtrl)
is RemoteCtrlConnecting ->
"remote ctrl: " +
(if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) +
"\nctrlAppInfo:\n${json.encodeToString(ctrlAppInfo)}" +
"\nappVersion: $appVersion"
is RemoteCtrlSessionCode ->
"remote ctrl: " +
(if (remoteCtrl_ == null) "null" else json.encodeToString(remoteCtrl_)) +
"\nsessionCode: $sessionCode"
is RemoteCtrlConnected -> json.encodeToString(remoteCtrl)
is RemoteCtrlStopped -> noDetails()
is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" +
@@ -4102,6 +4196,26 @@ data class CoreVersionInfo(
val simplexmqCommit: String
)
data class SomeRemoteCtrl(
val remoteCtrl_: RemoteCtrlInfo?,
val ctrlAppInfo: CtrlAppInfo,
val appVersion: String
)
@Serializable
data class CtrlAppInfo(val appVersionRange: AppVersionRange, val deviceName: String)
@Serializable
data class AppVersionRange(val minVersion: String, val maxVersion: String)
@Serializable
data class RemoteFile(
val userId: Long,
val fileId: Long,
val sent: Boolean,
val fileSource: CryptoFile
)
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
@@ -4624,18 +4738,20 @@ sealed class ArchiveError {
sealed class RemoteHostError {
val string: String get() = when (this) {
is Missing -> "missing"
is Inactive -> "inactive"
is Busy -> "busy"
is Rejected -> "rejected"
is Timeout -> "timeout"
is BadState -> "badState"
is BadVersion -> "badVersion"
is Disconnected -> "disconnected"
is ConnectionLost -> "connectionLost"
}
@Serializable @SerialName("missing") object Missing: RemoteHostError()
@Serializable @SerialName("inactive") object Inactive: RemoteHostError()
@Serializable @SerialName("busy") object Busy: RemoteHostError()
@Serializable @SerialName("rejected") object Rejected: RemoteHostError()
@Serializable @SerialName("timeout") object Timeout: RemoteHostError()
@Serializable @SerialName("badState") object BadState: RemoteHostError()
@Serializable @SerialName("badVersion") object BadVersion: RemoteHostError()
@Serializable @SerialName("disconnected") class Disconnected(val reason: String): RemoteHostError()
@Serializable @SerialName("connectionLost") class ConnectionLost(val reason: String): RemoteHostError()
}
@Serializable
@@ -15,6 +15,7 @@ external fun pipeStdOutToSocket(socketName: String) : Int
typealias ChatCtrl = Long
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatSendRemoteCmd(ctrl: ChatCtrl, rhId: Int, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
@@ -46,6 +46,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
val rhId = remember { chatModel.currentRemoteHost }.value?.remoteHostId
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
mutableStateOf(
@@ -284,10 +285,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
},
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
withApi { chatModel.controller.receiveFile(rhId, user, fileId, encrypted) }
},
cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) }
withApi { chatModel.controller.cancelFile(rhId, user, fileId) }
},
joinGroup = { groupId, onComplete ->
withApi {
@@ -590,7 +590,7 @@ private fun ShrinkItemAction(revealed: MutableState<Boolean>, showMenu: MutableS
}
@Composable
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) {
val finalColor = if (color == Color.Unspecified) {
if (isInDarkTheme()) MenuTextColorDark else Color.Black
} else color
@@ -1,6 +1,5 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -9,30 +8,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.views.usersettings.simplexTeamUri
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
@@ -77,7 +68,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
@@ -113,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList)
} else if (!switchingUsers.value) {
} else if (!switchingUsersAndHosts.value) {
Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet)
@@ -129,11 +120,12 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
if (switchingUsers.value) {
if (switchingUsersAndHosts.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
@@ -224,7 +216,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
@@ -254,14 +246,25 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 37.dp
)
if (!allRead) {
unreadBadge()
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 37.dp
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
}
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState, switchingUsers) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold(
Modifier.padding(end = endPadding),
@@ -47,8 +47,9 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
})
}
}
@@ -72,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
val navButton: @Composable RowScope.() -> Unit = {
when {
showSearch -> NavigationButtonBack(hideSearchOnBack)
users.size > 1 -> {
users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> {
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
@@ -4,10 +4,12 @@ import SectionItemView
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color
@@ -18,12 +20,15 @@ import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.User
import chat.simplex.common.platform.*
import chat.simplex.common.views.remote.connectMobileDevice
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -33,7 +38,7 @@ import kotlin.math.roundToInt
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
switchingUsers: MutableState<Boolean>,
switchingUsersAndHosts: MutableState<Boolean>,
showSettings: Boolean = true,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
@@ -53,6 +58,12 @@ fun UserPicker(
.sortedByDescending { it.user.activeUser }
}
}
val remoteHosts by remember {
derivedStateOf {
chatModel.remoteHosts
.sortedBy { it.hostDeviceName }
}
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
@@ -90,8 +101,42 @@ fun UserPicker(
} catch (e: Exception) {
Log.e(TAG, "Error updating users ${e.stackTraceToString()}")
}
if (!appPlatform.isDesktop) return@collect
try {
val updatedHosts = chatModel.controller.listRemoteHosts()?.sortedBy { it.hostDeviceName } ?: emptyList()
if (remoteHosts != updatedHosts) {
chatModel.remoteHosts.clear()
chatModel.remoteHosts.addAll(updatedHosts)
}
} catch (e: Exception) {
Log.e(TAG, "Error updating remote hosts ${e.stackTraceToString()}")
}
}
}
LaunchedEffect(Unit) {
controller.reloadRemoteHosts()
}
val UsersView: @Composable ColumnScope.() -> Unit = {
users.forEach { u ->
UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
scope.launch {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.userId, null)
job.cancel()
switchingUsersAndHosts.value = false
}
}
}
Divider(Modifier.requiredHeight(1.dp))
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
@@ -113,48 +158,63 @@ fun UserPicker(
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
users.forEach { u ->
UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = {
settingsClicked()
userPickerState.value = AnimatedViewState.GONE
}) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
scope.launch {
val job = launch {
delay(500)
switchingUsers.value = true
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.userId, null)
job.cancel()
switchingUsers.value = false
}
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
}
UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) {
LocalDevicePickerItem(false) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
}
remoteHosts.filter { !it.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(h,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, false)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, switchingUsersAndHosts, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
}
}
if (showSettings) {
SettingsPickerItem {
settingsClicked()
userPickerState.value = AnimatedViewState.GONE
}
SettingsPickerItem(settingsClicked)
}
if (showCancel) {
CancelPickerItem {
cancelClicked()
userPickerState.value = AnimatedViewState.GONE
}
CancelPickerItem(cancelClicked)
}
}
}
}
@Composable
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
@@ -166,7 +226,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
indication = if (!u.activeUser) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(padding),
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -219,16 +279,97 @@ fun UserProfileRow(u: User) {
}
}
@Composable
fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = 46.dp)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
RemoteHostRow(h)
if (h.sessionState is RemoteHostSessionState.Connected) {
HostDisconnectButton(actionButtonClick)
} else {
Box(Modifier.size(20.dp))
}
}
}
@Composable
fun RemoteHostRow(h: RemoteHostInfo) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Text(
h.hostDeviceName,
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (h.activeHost) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black,
fontSize = 14.sp,
)
}
}
@Composable
fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = 46.dp)
.combinedClickable(
onClick = if (active) {{}} else onClick,
onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() },
indication = if (!active) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
LocalDeviceRow(active)
Box(Modifier.size(20.dp))
}
}
@Composable
fun LocalDeviceRow(active: Boolean) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp, end = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Text(
stringResource(MR.strings.this_device),
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (active) MaterialTheme.colors.onBackground else if (isInDarkTheme()) MenuTextColorDark else Color.Black,
fontSize = 14.sp,
)
}
}
@Composable
private fun SettingsPickerItem(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_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), 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,
)
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
}
}
@@ -238,9 +379,47 @@ private fun CancelPickerItem(onClick: () -> Unit) {
val text = generalGetString(MR.strings.cancel_verb)
Icon(painterResource(MR.images.ic_close), 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,
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
}
}
@Composable
fun HostDisconnectButton(onClick: (() -> Unit)?) {
val interactionSource = remember { MutableInteractionSource() }
val hovered = interactionSource.collectIsHoveredAsState().value
IconButton(onClick ?: {}, Modifier.requiredSize(20.dp), enabled = onClick != null) {
Icon(
painterResource(if (onClick == null) MR.images.ic_desktop else if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
null,
Modifier.size(20.dp).hoverable(interactionSource),
tint = if (hovered && onClick != null) WarningOrange else MaterialTheme.colors.onBackground
)
}
}
private fun switchToLocalDevice() {
withBGApi {
chatController.switchUIRemoteHost(null)
}
}
private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState<Boolean>, connecting: MutableState<Boolean>) {
if (!h.activeHost()) {
withBGApi {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere()
if (h.sessionState != null) {
chatModel.controller.switchUIRemoteHost(h.remoteHostId)
} else {
connectMobileDevice(h, connecting)
}
job.cancel()
switchingUsersAndHosts.value = false
}
} else {
connectMobileDevice(h, connecting)
}
}
@@ -12,6 +12,7 @@ import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
@Composable
fun ModalView(
@@ -86,7 +87,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
else runAtomically { toRemove.add(modalViews.lastIndex - toRemove.size) }
else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) }
}
modalCount.value = modalViews.size - toRemove.size
}
@@ -0,0 +1,359 @@
package chat.simplex.common.views.remote
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewLongClickable
import SectionTextFooter
import SectionView
import TextIconSpaced
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
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.SettingsActionItemWithContent
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ConnectMobileView(
m: ChatModel
) {
val connecting = rememberSaveable() { mutableStateOf(false) }
val remoteHosts = remember { chatModel.remoteHosts }
val deviceName = m.controller.appPrefs.deviceNameForRemoteAccess
LaunchedEffect(Unit) {
controller.reloadRemoteHosts()
}
ConnectMobileLayout(
deviceName = remember { deviceName.state },
remoteHosts = remoteHosts,
connecting,
connectedHost = remember { m.currentRemoteHost },
updateDeviceName = {
withBGApi {
if (it != "") {
m.controller.setLocalDeviceName(it)
deviceName.set(it)
}
}
},
addMobileDevice = { showAddingMobileDevice(connecting) },
connectMobileDevice = { connectMobileDevice(it, connecting) },
connectDesktop = { withBGApi { chatController.switchUIRemoteHost(null) } },
deleteHost = { host ->
withBGApi {
val success = controller.deleteRemoteHost(host.remoteHostId)
if (success) {
chatModel.remoteHosts.removeAll { it.remoteHostId == host.remoteHostId }
}
}
}
)
}
@Composable
fun ConnectMobileLayout(
deviceName: State<String?>,
remoteHosts: List<RemoteHostInfo>,
connecting: MutableState<Boolean>,
connectedHost: State<RemoteHostInfo?>,
updateDeviceName: (String) -> Unit,
addMobileDevice: () -> Unit,
connectMobileDevice: (RemoteHostInfo) -> Unit,
connectDesktop: () -> Unit,
deleteHost: (RemoteHostInfo) -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles))
SectionView(generalGetString(MR.strings.this_device_name).uppercase()) {
DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) }
SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile))
SectionDividerSpaced(maxBottomPadding = false)
}
SectionView(stringResource(MR.strings.devices).uppercase()) {
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
}
for (host in remoteHosts) {
val showMenu = rememberSaveable { mutableStateOf(false) }
SectionItemViewLongClickable({ connectMobileDevice(host) }, { showMenu.value = true }, disabled = connecting.value) {
Icon(painterResource(MR.images.ic_smartphone_300), host.hostDeviceName, tint = MaterialTheme.colors.secondary)
TextIconSpaced(false)
Text(host.hostDeviceName)
Spacer(Modifier.weight(1f))
if (host.activeHost) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (host.sessionState is RemoteHostSessionState.Connected) {
HostDisconnectButton { stopRemoteHostAndReloadHosts(host, false) }
}
}
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
DefaultDropdownMenu(showMenu) {
if (host.activeHost) {
ItemAction(stringResource(MR.strings.disconnect_remote_host), painterResource(MR.images.ic_wifi_off), color = WarningOrange) {
stopRemoteHostAndReloadHosts(host, true)
showMenu.value = false
}
} else {
ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) {
deleteHost(host)
showMenu.value = false
}
}
}
}
}
SectionItemView(addMobileDevice) {
Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.link_a_mobile), tint = MaterialTheme.colors.primary)
Spacer(Modifier.padding(horizontal = 10.dp))
Text(stringResource(MR.strings.link_a_mobile), color = MaterialTheme.colors.primary)
}
}
SectionBottomSpacer()
}
}
@Composable
private fun DeviceNameField(
initialValue: String,
onChange: (String) -> Unit
) {
// TODO get user-defined device name
val state = remember { mutableStateOf(TextFieldValue(initialValue)) }
DefaultConfigurableTextField(
state = state,
placeholder = generalGetString(MR.strings.enter_this_device_name),
modifier = Modifier.padding(start = DEFAULT_PADDING),
isValid = { true },
)
KeyChangeEffect(state.value) {
onChange(state.value.text)
}
}
@Composable
private fun ConnectMobileViewLayout(
title: String,
invitation: String?,
deviceName: String?,
sessionCode: String?
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(title)
SectionView {
if (invitation != null && sessionCode == null) {
QRCode(
invitation, Modifier
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code))
if (remember { controller.appPrefs.developerTools.state }.value) {
val clipboard = LocalClipboardManager.current
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
SectionItemView({ clipboard.shareText(invitation) }) {
Text(generalGetString(MR.strings.share_link), color = MaterialTheme.colors.primary)
}
}
Spacer(Modifier.height(DEFAULT_PADDING))
}
if (deviceName != null || sessionCode != null) {
SectionView(stringResource(MR.strings.connected_mobile).uppercase()) {
SelectionContainer {
Text(
deviceName ?: stringResource(MR.strings.new_mobile_device),
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp, fontStyle = if (deviceName != null) FontStyle.Normal else FontStyle.Italic)
)
}
}
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
}
if (sessionCode != null) {
SectionView(stringResource(MR.strings.verify_code_on_mobile).uppercase()) {
SelectionContainer {
Text(
sessionCode.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)
)
}
}
}
}
}
}
fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
if (!rh.activeHost() && rh.sessionState is RemoteHostSessionState.Connected) {
withBGApi {
controller.switchUIRemoteHost(rh.remoteHostId)
}
} else if (rh.activeHost()) {
showConnectedMobileDevice(rh) {
stopRemoteHostAndReloadHosts(rh, true)
}
} else {
showConnectMobileDevice(rh, connecting)
}
}
private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
ModalManager.start.showModalCloseable { close ->
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val pairing = remember { chatModel.newRemoteHostPairing }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
}
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
val remoteDeviceName = pairing.value?.first?.hostDeviceName
ConnectMobileViewLayout(
title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = remoteDeviceName,
sessionCode = cachedSessionCode
)
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
withBGApi {
val r = chatModel.controller.startRemoteHost(null)
if (r != null) {
connecting.value = true
invitation.value = r.second
}
}
onDispose {
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
withBGApi {
chatController.stopRemoteHost(null)
}
}
chatModel.newRemoteHostPairing.value = null
}
}
}
}
private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
ModalManager.start.showModalCloseable { close ->
val pairing = remember { chatModel.newRemoteHostPairing }
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
}
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
ConnectMobileViewLayout(
title = if (cachedSessionCode == null) stringResource(MR.strings.scan_from_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName,
sessionCode = cachedSessionCode,
)
var remoteHostId by rememberSaveable { mutableStateOf<Long?>(null) }
LaunchedEffect(Unit) {
val r = chatModel.controller.startRemoteHost(rh.remoteHostId)
if (r != null) {
val (rh_, inv) = r
connecting.value = true
remoteHostId = rh_?.remoteHostId
invitation.value = inv
}
}
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
onDispose {
if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != remoteHostId) {
withBGApi {
chatController.stopRemoteHost(remoteHostId)
}
}
chatModel.newRemoteHostPairing.value = null
}
}
}
}
private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> Unit) {
ModalManager.start.showModalCloseable { close ->
val sessionCode = when (val state = rh.sessionState) {
is RemoteHostSessionState.Connected -> state.sessionCode
else -> null
}
Column {
ConnectMobileViewLayout(
title = stringResource(MR.strings.connected_to_mobile),
invitation = null,
deviceName = rh.hostDeviceName,
sessionCode = sessionCode
)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
SectionItemView(disconnectHost) {
Text(generalGetString(MR.strings.disconnect_remote_host), Modifier.fillMaxWidth(), color = WarningOrange)
}
}
KeyChangeEffect(remember { chatModel.currentRemoteHost }.value) {
close()
}
}
}
@@ -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.ConnectMobileView
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@@ -155,6 +156,9 @@ fun SettingsLayout(
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
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)
}
}
SectionDividerSpaced()
@@ -1625,6 +1625,24 @@
<string name="you_can_enable_delivery_receipts_later_alert">You can enable them later via app Privacy &amp; Security settings.</string>
<string name="error_enabling_delivery_receipts">Error enabling delivery receipts!</string>
<!-- Remote access -->
<string name="link_a_mobile">Link a mobile</string>
<string name="linked_mobiles">Linked mobiles</string>
<string name="scan_from_mobile">Scan from mobile</string>
<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="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>
<string name="this_device_name_shared_with_mobile">The device name will be shared with the connected mobile client.</string>
<string name="error">Error</string>
<string name="this_device">This device</string>
<string name="devices">Devices</string>
<string name="new_mobile_device">New mobile device</string>
<string name="disconnect_remote_host">Disconnect</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>
<!-- Under development -->
<string name="in_developing_title">Coming soon!</string>
<string name="in_developing_desc">This feature is not yet supported. Try the next release.</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M261.5-45q-22.969 0-40.234-17.266Q204-79.53 204-102.5v-755q0-22.969 17.266-40.234Q238.531-915 261.5-915h437q22.969 0 40.234 17.266Q756-880.469 756-857.5v755q0 22.969-17.266 40.234Q721.469-45 698.5-45h-437Zm0-88.5v31h437v-31h-437Zm0-57.5h437v-578h-437v578Zm0-635.5h437v-31h-437v31Zm0 0v-31 31Zm0 693v31-31Z"/></svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M278.533-63.078q-22.606 0-39.338-16.732t-16.732-39.327v-721.726q0-22.595 16.732-39.327t39.338-16.732h402.934q22.606 0 39.338 16.732t16.732 39.327v721.726q0 22.595-16.732 39.327t-39.338 16.732H278.533Zm-12.225-87.154v31q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h402.766q4.616 0 8.463-3.847 3.846-3.846 3.846-8.462v-31H266.308Zm0-43.845h427.384v-571.846H266.308v571.846Zm0-615.691h427.384v-31q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H278.617q-4.616 0-8.463 3.847-3.846 3.846-3.846 8.462v31Zm0 0V-853.077v43.309Zm0 659.536V-106.923v-43.309Z"/></svg>

After

Width:  |  Height:  |  Size: 648 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M479.965-133.5q-34.965 0-61.215-26.295-26.25-26.296-26.25-61.275 0-34.43 26.285-60.93t61.25-26.5q34.965 0 61.215 26.57 26.25 26.571 26.25 61.25 0 34.68-26.285 60.93t-61.25 26.25ZM234-361l-60.5-59.5Q243-490 318-524.5T480-559q87 0 162.026 34.538Q717.053-489.923 786.5-421l-60 60q-61-60.5-122.532-86.25-61.533-25.75-124-25.75Q417.5-473 356-447.25T234-361ZM70.5-524.5l-60.5-60q91-93 211.947-150t258-57Q617-791.5 738-734.5t212 150l-60 60Q802.5-607 700.1-656.25t-220-49.25q-117.6 0-220.1 49.25T70.5-524.5Z"/></svg>

After

Width:  |  Height:  |  Size: 605 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m803.5-74-392-391.5Q357-453 312.25-424T234-361l-60.5-59.5q37-37 75.75-64.75T343.5-535l-108-108q-47 23-88.5 54t-76.5 64.5L10-584q36-37 76.5-68.75T170-707.5L76-802l41-41 727.5 727.5-41 41.5ZM480-133.5q-35 0-61.25-26T392.5-221q0-34.5 26.25-61T480-308.5q35 0 61.25 26.5t26.25 61q0 35.5-26.25 61.5t-61.25 26Zm245.5-226Q693-391 666-410.5t-68.5-38l-110-110q93 2 164.25 38.75T786.5-420.5l-61 61Zm164.5-165q-87.5-83-190.25-132T480-705.5q-37 0-69.5 4.5t-54 12l-70-70q42.5-15 91.75-23.75T480-791.5q137 0 257.5 56.75T950-584l-60 59.5Z"/></svg>

After

Width:  |  Height:  |  Size: 628 B