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:
parent
c31ae39617
commit
0322b9708b
@ -85,6 +85,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var activeCall: Call?
|
||||
@Published var callCommand: WCallCommand?
|
||||
@Published var showCallView = false
|
||||
// remote desktop
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@ -110,6 +112,10 @@ final class ChatModel: ObservableObject {
|
||||
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
|
||||
}
|
||||
|
||||
var activeRemoteCtrl: Bool {
|
||||
remoteCtrlSession?.active ?? false
|
||||
}
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@ -762,3 +768,32 @@ final class GMember: ObservableObject, Identifiable {
|
||||
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
|
||||
static let sampleData = GMember(GroupMember.sampleData)
|
||||
}
|
||||
|
||||
struct RemoteCtrlSession {
|
||||
var ctrlAppInfo: CtrlAppInfo
|
||||
var appVersion: String
|
||||
var sessionState: UIRemoteCtrlSessionState
|
||||
|
||||
func updateState(_ state: UIRemoteCtrlSessionState) -> RemoteCtrlSession {
|
||||
RemoteCtrlSession(ctrlAppInfo: ctrlAppInfo, appVersion: appVersion, sessionState: state)
|
||||
}
|
||||
|
||||
var active: Bool {
|
||||
if case .connected = sessionState { true } else { false }
|
||||
}
|
||||
|
||||
var sessionCode: String? {
|
||||
switch sessionState {
|
||||
case let .pendingConfirmation(_, sessionCode): sessionCode
|
||||
case let .connected(_, sessionCode): sessionCode
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UIRemoteCtrlSessionState {
|
||||
case starting
|
||||
case connecting(remoteCtrl_: RemoteCtrlInfo?)
|
||||
case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
|
||||
case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String)
|
||||
}
|
||||
|
@ -905,30 +905,36 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
}
|
||||
}
|
||||
|
||||
func startRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.startRemoteCtrl)
|
||||
func setLocalDeviceName(_ displayName: String) throws {
|
||||
try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName))
|
||||
}
|
||||
|
||||
func registerRemoteCtrl(_ remoteCtrlOOB: RemoteCtrlOOB) async throws -> RemoteCtrlInfo {
|
||||
let r = await chatSendCmd(.registerRemoteCtrl(remoteCtrlOOB: remoteCtrlOOB))
|
||||
if case let .remoteCtrlRegistered(rcInfo) = r { return rcInfo }
|
||||
func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
|
||||
let r = await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress))
|
||||
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func listRemoteCtrls() async throws -> [RemoteCtrlInfo] {
|
||||
let r = await chatSendCmd(.listRemoteCtrls)
|
||||
func findKnownRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.findKnownRemoteCtrl)
|
||||
}
|
||||
|
||||
func confirmRemoteCtrl(_ rcId: Int64) async throws {
|
||||
try await sendCommandOkResp(.confirmRemoteCtrl(remoteCtrlId: rcId))
|
||||
}
|
||||
|
||||
func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo {
|
||||
let r = await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode))
|
||||
if case let .remoteCtrlConnected(rc) = r { return rc }
|
||||
throw r
|
||||
}
|
||||
|
||||
func listRemoteCtrls() throws -> [RemoteCtrlInfo] {
|
||||
let r = chatSendCmdSync(.listRemoteCtrls)
|
||||
if case let .remoteCtrlList(rcInfo) = r { return rcInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptRemoteCtrl(_ rcId: Int64) async throws {
|
||||
try await sendCommandOkResp(.acceptRemoteCtrl(remoteCtrlId: rcId))
|
||||
}
|
||||
|
||||
func rejectRemoteCtrl(_ rcId: Int64) async throws {
|
||||
try await sendCommandOkResp(.rejectRemoteCtrl(remoteCtrlId: rcId))
|
||||
}
|
||||
|
||||
func stopRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.stopRemoteCtrl)
|
||||
}
|
||||
@ -1065,6 +1071,12 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
private func sendCommandOkRespSync(_ cmd: ChatCommand) throws {
|
||||
let r = chatSendCmdSync(cmd)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
|
||||
let userId = try currentUserId("apiNewGroup")
|
||||
let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
|
||||
@ -1702,6 +1714,24 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
|
||||
}
|
||||
case let .remoteCtrlFound(remoteCtrl):
|
||||
// TODO multicast
|
||||
logger.debug("\(String(describing: remoteCtrl))")
|
||||
case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode):
|
||||
await MainActor.run {
|
||||
let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode)
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||
}
|
||||
case let .remoteCtrlConnected(remoteCtrl):
|
||||
// TODO currently it is returned in response to command, so it is redundant
|
||||
await MainActor.run {
|
||||
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
|
||||
}
|
||||
case .remoteCtrlStopped:
|
||||
await MainActor.run {
|
||||
switchToLocalSession()
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
@ -1715,6 +1745,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
}
|
||||
|
||||
func switchToLocalSession() {
|
||||
let m = ChatModel.shared
|
||||
m.remoteCtrlSession = nil
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
} catch let error {
|
||||
logger.debug("error updating chat data: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func active(_ user: any UserLike) -> Bool {
|
||||
user.userId == ChatModel.shared.currentUser?.id
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ struct ChatListView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var showAddChat = false
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
@ -48,7 +49,14 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible)
|
||||
UserPicker(
|
||||
showSettings: $showSettings,
|
||||
showConnectDesktop: $showConnectDesktop,
|
||||
userPickerVisible: $userPickerVisible
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showConnectDesktop) {
|
||||
ConnectDesktopView()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ struct UserPicker: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showConnectDesktop: Bool
|
||||
@Binding var userPickerVisible: Bool
|
||||
@State var scrollViewContentSize: CGSize = .zero
|
||||
@State var disableScrolling: Bool = true
|
||||
@ -62,6 +63,13 @@ struct UserPicker: View {
|
||||
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
|
||||
.frame(maxHeight: scrollViewContentSize.height)
|
||||
|
||||
menuButton("Use from desktop", icon: "desktopcomputer") {
|
||||
showConnectDesktop = true
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
menuButton("Settings", icon: "gearshape") {
|
||||
showSettings = true
|
||||
withAnimation {
|
||||
@ -85,7 +93,7 @@ struct UserPicker: View {
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
} catch let error {
|
||||
logger.error("Error updating users \(responseError(error))")
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -144,7 +152,8 @@ struct UserPicker: View {
|
||||
.overlay(DetermineWidth())
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
// .frame(width: 24, alignment: .center)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 22)
|
||||
@ -170,6 +179,7 @@ struct UserPicker_Previews: PreviewProvider {
|
||||
m.users = [UserInfo.sampleData, UserInfo.sampleData]
|
||||
return UserPicker(
|
||||
showSettings: Binding.constant(false),
|
||||
showConnectDesktop: Binding.constant(false),
|
||||
userPickerVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(m)
|
||||
|
434
apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
Normal file
434
apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift
Normal file
@ -0,0 +1,434 @@
|
||||
//
|
||||
// ConnectDesktopView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 13/10/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
|
||||
struct ConnectDesktopView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var viaSettings = false
|
||||
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
|
||||
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
|
||||
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = false
|
||||
@AppStorage(DEFAULT_OFFER_REMOTE_MULTICAST) private var offerRemoteMulticast = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var sessionAddress: String = ""
|
||||
@State private var remoteCtrls: [RemoteCtrlInfo] = []
|
||||
@State private var alert: ConnectDesktopAlert?
|
||||
|
||||
private enum ConnectDesktopAlert: Identifiable {
|
||||
case unlinkDesktop(rc: RemoteCtrlInfo)
|
||||
case disconnectDesktop(action: UserDisconnectAction)
|
||||
case badInvitationError
|
||||
case badVersionError(version: String?)
|
||||
case desktopDisconnectedError
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .unlinkDesktop(rc): "unlinkDesktop \(rc.remoteCtrlId)"
|
||||
case let .disconnectDesktop(action): "disconnectDecktop \(action)"
|
||||
case .badInvitationError: "badInvitationError"
|
||||
case let .badVersionError(v): "badVersionError \(v ?? "")"
|
||||
case .desktopDisconnectedError: "desktopDisconnectedError"
|
||||
case let .error(title, _): "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum UserDisconnectAction: String {
|
||||
case back
|
||||
case dismiss // TODO dismiss settings after confirmation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
.modifier(BackButton(label: "Back") {
|
||||
if m.activeRemoteCtrl {
|
||||
alert = .disconnectDesktop(action: .back)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
NavigationView {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewBody: some View {
|
||||
Group {
|
||||
if let session = m.remoteCtrlSession {
|
||||
switch session.sessionState {
|
||||
case .starting: connectingDesktopView(session, nil)
|
||||
case let .connecting(rc_): connectingDesktopView(session, rc_)
|
||||
case let .pendingConfirmation(rc_, sessCode):
|
||||
if confirmRemoteSessions || rc_ == nil {
|
||||
verifySessionView(session, rc_, sessCode)
|
||||
} else {
|
||||
connectingDesktopView(session, rc_).onAppear {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
}
|
||||
}
|
||||
case let .connected(rc, _): activeSessionView(session, rc)
|
||||
}
|
||||
} else {
|
||||
connectDesktopView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setDeviceName(deviceName)
|
||||
updateRemoteCtrls()
|
||||
}
|
||||
.onDisappear {
|
||||
if m.remoteCtrlSession != nil {
|
||||
disconnectDesktop()
|
||||
}
|
||||
}
|
||||
.onChange(of: deviceName) {
|
||||
setDeviceName($0)
|
||||
}
|
||||
.onChange(of: m.activeRemoteCtrl) {
|
||||
UIApplication.shared.isIdleTimerDisabled = $0
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .unlinkDesktop(rc):
|
||||
Alert(
|
||||
title: Text("Unlink desktop?"),
|
||||
primaryButton: .destructive(Text("Unlink")) {
|
||||
unlinkDesktop(rc)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .disconnectDesktop(action):
|
||||
Alert(
|
||||
title: Text("Disconnect desktop?"),
|
||||
primaryButton: .destructive(Text("Disconnect")) {
|
||||
disconnectDesktop(action)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .badInvitationError:
|
||||
Alert(title: Text("Bad desktop address"))
|
||||
case let .badVersionError(v):
|
||||
Alert(
|
||||
title: Text("Incompatible version"),
|
||||
message: Text("Desktop app version \(v ?? "") is not compatible with this app.")
|
||||
)
|
||||
case .desktopDisconnectedError:
|
||||
Alert(title: Text("Connection terminated"))
|
||||
case let .error(title, error):
|
||||
Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||
}
|
||||
|
||||
private func connectDesktopView() -> some View {
|
||||
List {
|
||||
Section("This device name") {
|
||||
devicesView()
|
||||
}
|
||||
scanDesctopAddressView()
|
||||
if developerTools {
|
||||
desktopAddressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect to desktop")
|
||||
}
|
||||
|
||||
private func connectingDesktopView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> some View {
|
||||
List {
|
||||
Section("Connecting to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connecting to desktop")
|
||||
}
|
||||
|
||||
private func verifySessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?, _ sessCode: String) -> some View {
|
||||
List {
|
||||
Section("Connected to desktop") {
|
||||
ctrlDeviceNameText(session, rc)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
Section("Verify code with desktop") {
|
||||
sessionCodeText(sessCode)
|
||||
Button {
|
||||
verifyDesktopSessionCode(sessCode)
|
||||
} label: {
|
||||
Label("Confirm", systemImage: "checkmark")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verify connection")
|
||||
}
|
||||
|
||||
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
|
||||
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo.deviceName)
|
||||
if (rc == nil) {
|
||||
t = t + Text(" ") + Text("(new)").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func ctrlDeviceVersionText(_ session: RemoteCtrlSession) -> Text {
|
||||
let v = session.ctrlAppInfo.appVersionRange.maxVersion
|
||||
var t = Text("v\(v)")
|
||||
if v != session.appVersion {
|
||||
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private func activeSessionView(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo) -> some View {
|
||||
List {
|
||||
Section("Connected desktop") {
|
||||
Text(rc.deviceViewName)
|
||||
ctrlDeviceVersionText(session)
|
||||
}
|
||||
|
||||
if let sessCode = session.sessionCode {
|
||||
Section("Session code") {
|
||||
sessionCodeText(sessCode)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
disconnectButton()
|
||||
} footer: {
|
||||
// This is specific to iOS
|
||||
Text("Keep the app open to use it from desktop")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connected to desktop")
|
||||
}
|
||||
|
||||
private func sessionCodeText(_ code: String) -> some View {
|
||||
Text(code.prefix(23))
|
||||
}
|
||||
|
||||
private func devicesView() -> some View {
|
||||
Group {
|
||||
TextField("Enter this device name…", text: $deviceName)
|
||||
if !remoteCtrls.isEmpty {
|
||||
NavigationLink {
|
||||
linkedDesktopsView()
|
||||
} label: {
|
||||
Text("Linked desktops")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanDesctopAddressView() -> some View {
|
||||
Section("Scan QR code from desktop") {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func desktopAddressView() -> some View {
|
||||
Section("Desktop address") {
|
||||
if sessionAddress.isEmpty {
|
||||
Button {
|
||||
sessionAddress = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Label("Paste desktop address", systemImage: "doc.plaintext")
|
||||
}
|
||||
.disabled(!UIPasteboard.general.hasStrings)
|
||||
} else {
|
||||
HStack {
|
||||
Text(sessionAddress).lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.onTapGesture { sessionAddress = "" }
|
||||
}
|
||||
}
|
||||
Button {
|
||||
connectDesktopAddress(sessionAddress)
|
||||
} label: {
|
||||
Label("Connect to desktop", systemImage: "rectangle.connected.to.line.below")
|
||||
}
|
||||
.disabled(sessionAddress.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkedDesktopsView() -> some View {
|
||||
List {
|
||||
Section("Desktop devices") {
|
||||
ForEach(remoteCtrls, id: \.remoteCtrlId) { rc in
|
||||
remoteCtrlView(rc)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
if let i = indexSet.first, i < remoteCtrls.count {
|
||||
alert = .unlinkDesktop(rc: remoteCtrls[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Linked desktop options") {
|
||||
Toggle("Verify connections", isOn: $confirmRemoteSessions)
|
||||
Toggle("Discover on network", isOn: $connectRemoteViaMulticast).disabled(true)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Linked desktops")
|
||||
}
|
||||
|
||||
private func remoteCtrlView(_ rc: RemoteCtrlInfo) -> some View {
|
||||
Text(rc.deviceViewName)
|
||||
}
|
||||
|
||||
|
||||
private func setDeviceName(_ name: String) {
|
||||
do {
|
||||
try setLocalDeviceName(deviceName)
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteCtrls() {
|
||||
do {
|
||||
remoteCtrls = try listRemoteCtrls()
|
||||
} catch let e {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func processDesktopQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r): connectDesktopAddress(r.string)
|
||||
case let .failure(e): errorAlert(e)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectDesktopAddress(_ addr: String) {
|
||||
Task {
|
||||
do {
|
||||
let (rc_, ctrlAppInfo, v) = try await connectRemoteCtrl(desktopAddress: addr)
|
||||
await MainActor.run {
|
||||
sessionAddress = ""
|
||||
m.remoteCtrlSession = RemoteCtrlSession(
|
||||
ctrlAppInfo: ctrlAppInfo,
|
||||
appVersion: v,
|
||||
sessionState: .connecting(remoteCtrl_: rc_)
|
||||
)
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
switch e as? ChatResponse {
|
||||
case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError
|
||||
case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError
|
||||
case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil)
|
||||
case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError
|
||||
default: errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyDesktopSessionCode(_ sessCode: String) {
|
||||
Task {
|
||||
do {
|
||||
let rc = try await verifyRemoteCtrlSession(sessCode)
|
||||
await MainActor.run {
|
||||
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(.connected(remoteCtrl: rc, sessionCode: sessCode))
|
||||
}
|
||||
await MainActor.run {
|
||||
updateRemoteCtrls()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
errorAlert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectButton() -> some View {
|
||||
Button {
|
||||
disconnectDesktop()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "multiply")
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectDesktop(_ action: UserDisconnectAction? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await stopRemoteCtrl()
|
||||
await MainActor.run {
|
||||
switchToLocalSession()
|
||||
switch action {
|
||||
case .back: dismiss()
|
||||
case .dismiss: dismiss()
|
||||
case .none: ()
|
||||
}
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unlinkDesktop(_ rc: RemoteCtrlInfo) {
|
||||
Task {
|
||||
do {
|
||||
try await deleteRemoteCtrl(rc.remoteCtrlId)
|
||||
await MainActor.run {
|
||||
remoteCtrls.removeAll(where: { $0.remoteCtrlId == rc.remoteCtrlId })
|
||||
}
|
||||
} catch let e {
|
||||
await MainActor.run {
|
||||
errorAlert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func errorAlert(_ error: Error) {
|
||||
let a = getErrorAlert(error, "Error")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConnectDesktopView()
|
||||
}
|
@ -53,6 +53,10 @@ let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
|
||||
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
|
||||
let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
|
||||
let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
|
||||
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||
let DEFAULT_OFFER_REMOTE_MULTICAST = "offerRemoteMulticast"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
@ -85,7 +89,10 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
|
||||
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
|
||||
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
|
||||
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false
|
||||
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false,
|
||||
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: false,
|
||||
DEFAULT_OFFER_REMOTE_MULTICAST: true
|
||||
]
|
||||
|
||||
enum SimpleXLinkMode: String, Identifiable {
|
||||
@ -178,6 +185,12 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("switch.2") { Text("Chat preferences") }
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ConnectDesktopView(viaSettings: true)
|
||||
} label: {
|
||||
settingsRow("desktopcomputer") { Text("Use from desktop") }
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
@ -362,7 +375,9 @@ struct SettingsView: View {
|
||||
|
||||
func settingsRow<Content : View>(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color)
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(color)
|
||||
content().padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
@ -18,5 +18,7 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -39,6 +39,7 @@
|
||||
5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C36027227F47AD5009F19D9 /* AppDelegate.swift */; };
|
||||
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; };
|
||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; };
|
||||
5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */; };
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; };
|
||||
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; };
|
||||
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; };
|
||||
@ -117,6 +118,11 @@
|
||||
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
|
||||
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
|
||||
5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A282B04FE2D00A71D61 /* libgmp.a */; };
|
||||
5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */; };
|
||||
5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */; };
|
||||
5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */; };
|
||||
5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */; };
|
||||
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
|
||||
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
@ -142,11 +148,6 @@
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
|
||||
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; };
|
||||
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; };
|
||||
5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF722AFF8D4D007893ED /* libffi.a */; };
|
||||
5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */; };
|
||||
5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */; };
|
||||
5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF752AFF8D4E007893ED /* libgmp.a */; };
|
||||
5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */; };
|
||||
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; };
|
||||
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; };
|
||||
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
|
||||
@ -282,6 +283,7 @@
|
||||
5C36027227F47AD5009F19D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = "<group>"; };
|
||||
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = "<group>"; };
|
||||
5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectDesktopView.swift; sourceTree = "<group>"; };
|
||||
5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = "<group>"; };
|
||||
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = "<group>"; };
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
|
||||
@ -397,6 +399,11 @@
|
||||
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
|
||||
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
|
||||
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
|
||||
5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = "<group>"; };
|
||||
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@ -423,11 +430,6 @@
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
|
||||
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
|
||||
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; };
|
||||
5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = "<group>"; };
|
||||
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = "<group>"; };
|
||||
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = "<group>"; };
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
|
||||
@ -505,13 +507,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CDA5A302B04FE2D00A71D61 /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CF4DF792AFF8D4E007893ED /* libgmpxx.a in Frameworks */,
|
||||
5CF4DF772AFF8D4E007893ED /* libffi.a in Frameworks */,
|
||||
5CDA5A2D2B04FE2D00A71D61 /* libgmp.a in Frameworks */,
|
||||
5CDA5A2E2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a in Frameworks */,
|
||||
5CDA5A2F2B04FE2D00A71D61 /* libffi.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5CF4DF7A2AFF8D4E007893ED /* libgmp.a in Frameworks */,
|
||||
5CF4DF7B2AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a in Frameworks */,
|
||||
5CF4DF782AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a in Frameworks */,
|
||||
5CDA5A312B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -544,6 +546,7 @@
|
||||
5CB924DD27A8622200ACCCDD /* NewChat */,
|
||||
5CFA59C22860B04D00863A68 /* Database */,
|
||||
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
|
||||
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
|
||||
5CB924DF27A8678B00ACCCDD /* UserSettings */,
|
||||
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
|
||||
);
|
||||
@ -572,11 +575,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CF4DF722AFF8D4D007893ED /* libffi.a */,
|
||||
5CF4DF752AFF8D4E007893ED /* libgmp.a */,
|
||||
5CF4DF742AFF8D4D007893ED /* libgmpxx.a */,
|
||||
5CF4DF732AFF8D4D007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8-ghc9.6.3.a */,
|
||||
5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */,
|
||||
5CDA5A2A2B04FE2D00A71D61 /* libffi.a */,
|
||||
5CDA5A282B04FE2D00A71D61 /* libgmp.a */,
|
||||
5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */,
|
||||
5CDA5A292B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL-ghc9.6.3.a */,
|
||||
5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@ -684,6 +687,14 @@
|
||||
path = "Tests iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */,
|
||||
);
|
||||
path = RemoteAccess;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CB0BA8C282711BC00B3292C /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -1170,6 +1181,7 @@
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */,
|
||||
5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */,
|
||||
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
|
||||
5C3CCFCC2AE6BD3100C3F0C3 /* ConnectDesktopView.swift in Sources */,
|
||||
5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */,
|
||||
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */,
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
|
@ -120,14 +120,16 @@ public enum ChatCommand {
|
||||
case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, encrypted: Bool?)
|
||||
case cancelFile(fileId: Int64)
|
||||
// remote desktop commands
|
||||
case setLocalDeviceName(displayName: String)
|
||||
case startRemoteCtrl
|
||||
case registerRemoteCtrl(remoteCtrlOOB: RemoteCtrlOOB)
|
||||
case connectRemoteCtrl(xrcpInvitation: String)
|
||||
case findKnownRemoteCtrl
|
||||
case confirmRemoteCtrl(remoteCtrlId: Int64)
|
||||
case verifyRemoteCtrlSession(sessionCode: String)
|
||||
case listRemoteCtrls
|
||||
case acceptRemoteCtrl(remoteCtrlId: Int64)
|
||||
case rejectRemoteCtrl(remoteCtrlId: Int64)
|
||||
case stopRemoteCtrl
|
||||
case deleteRemoteCtrl(remoteCtrlId: Int64)
|
||||
// misc
|
||||
case showVersion
|
||||
case string(String)
|
||||
|
||||
@ -269,10 +271,10 @@ public enum ChatCommand {
|
||||
case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
|
||||
case let .cancelFile(fileId): return "/fcancel \(fileId)"
|
||||
case let .setLocalDeviceName(displayName): return "/set device name \(displayName)"
|
||||
case .startRemoteCtrl: return "/start remote ctrl"
|
||||
case let .registerRemoteCtrl(oob): return "/register remote ctrl \(oob.caFingerprint)"
|
||||
case let .acceptRemoteCtrl(rcId): return "/accept remote ctrl \(rcId)"
|
||||
case let .rejectRemoteCtrl(rcId): return "/reject remote ctrl \(rcId)"
|
||||
case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)"
|
||||
case .findKnownRemoteCtrl: return "/find remote ctrl"
|
||||
case let .confirmRemoteCtrl(rcId): return "/confirm remote ctrl \(rcId)"
|
||||
case let .verifyRemoteCtrlSession(sessCode): return "/verify remote ctrl \(sessCode)"
|
||||
case .listRemoteCtrls: return "/list remote ctrls"
|
||||
case .stopRemoteCtrl: return "/stop remote ctrl"
|
||||
case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)"
|
||||
@ -392,11 +394,11 @@ public enum ChatCommand {
|
||||
case .setFileToReceive: return "setFileToReceive"
|
||||
case .cancelFile: return "cancelFile"
|
||||
case .setLocalDeviceName: return "setLocalDeviceName"
|
||||
case .startRemoteCtrl: return "startRemoteCtrl"
|
||||
case .registerRemoteCtrl: return "registerRemoteCtrl"
|
||||
case .connectRemoteCtrl: return "connectRemoteCtrl"
|
||||
case .findKnownRemoteCtrl: return "findKnownRemoteCtrl"
|
||||
case .confirmRemoteCtrl: return "confirmRemoteCtrl"
|
||||
case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession"
|
||||
case .listRemoteCtrls: return "listRemoteCtrls"
|
||||
case .acceptRemoteCtrl: return "acceptRemoteCtrl"
|
||||
case .rejectRemoteCtrl: return "rejectRemoteCtrl"
|
||||
case .stopRemoteCtrl: return "stopRemoteCtrl"
|
||||
case .deleteRemoteCtrl: return "deleteRemoteCtrl"
|
||||
case .showVersion: return "showVersion"
|
||||
@ -605,13 +607,14 @@ public enum ChatResponse: Decodable, Error {
|
||||
case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
|
||||
case newContactConnection(user: UserRef, connection: PendingContactConnection)
|
||||
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
|
||||
// remote desktop responses/events
|
||||
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
|
||||
case remoteCtrlRegistered(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlAnnounce(fingerprint: String)
|
||||
case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlConnecting(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
|
||||
case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
|
||||
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
|
||||
case remoteCtrlStopped
|
||||
// misc
|
||||
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
|
||||
case cmdOk(user: UserRef?)
|
||||
case chatCmdError(user_: UserRef?, chatError: ChatError)
|
||||
@ -752,10 +755,9 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .newContactConnection: return "newContactConnection"
|
||||
case .contactConnectionDeleted: return "contactConnectionDeleted"
|
||||
case .remoteCtrlList: return "remoteCtrlList"
|
||||
case .remoteCtrlRegistered: return "remoteCtrlRegistered"
|
||||
case .remoteCtrlAnnounce: return "remoteCtrlAnnounce"
|
||||
case .remoteCtrlFound: return "remoteCtrlFound"
|
||||
case .remoteCtrlConnecting: return "remoteCtrlConnecting"
|
||||
case .remoteCtrlSessionCode: return "remoteCtrlSessionCode"
|
||||
case .remoteCtrlConnected: return "remoteCtrlConnected"
|
||||
case .remoteCtrlStopped: return "remoteCtrlStopped"
|
||||
case .versionInfo: return "versionInfo"
|
||||
@ -901,10 +903,9 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .newContactConnection(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
|
||||
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
|
||||
case let .remoteCtrlRegistered(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlAnnounce(fingerprint): return "fingerprint: \(fingerprint)"
|
||||
case let .remoteCtrlFound(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlConnecting(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)"
|
||||
case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nsessionCode: \(sessionCode)"
|
||||
case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl)
|
||||
case .remoteCtrlStopped: return noDetails
|
||||
case let .versionInfo(versionInfo, chatMigrations, agentMigrations): return "\(String(describing: versionInfo))\n\nchat migrations: \(chatMigrations.map(\.upName))\n\nagent migrations: \(agentMigrations.map(\.upName))"
|
||||
@ -1533,21 +1534,31 @@ public enum NotificationPreviewMode: String, SelectableItem {
|
||||
public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden]
|
||||
}
|
||||
|
||||
public struct RemoteCtrlOOB {
|
||||
public var caFingerprint: String
|
||||
}
|
||||
|
||||
public struct RemoteCtrlInfo: Decodable {
|
||||
public var remoteCtrlId: Int64
|
||||
public var displayName: String
|
||||
public var sessionActive: Bool
|
||||
public var ctrlDeviceName: String
|
||||
public var sessionState: RemoteCtrlSessionState?
|
||||
|
||||
public var deviceViewName: String {
|
||||
ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName
|
||||
}
|
||||
}
|
||||
|
||||
public struct RemoteCtrl: Decodable {
|
||||
var remoteCtrlId: Int64
|
||||
var displayName: String
|
||||
var fingerprint: String
|
||||
var accepted: Bool?
|
||||
public enum RemoteCtrlSessionState: Decodable {
|
||||
case starting
|
||||
case connecting
|
||||
case pendingConfirmation(sessionCode: String)
|
||||
case connected(sessionCode: String)
|
||||
}
|
||||
|
||||
public struct CtrlAppInfo: Decodable {
|
||||
public var appVersionRange: AppVersionRange
|
||||
public var deviceName: String
|
||||
}
|
||||
|
||||
public struct AppVersionRange: Decodable {
|
||||
public var minVersion: String
|
||||
public var maxVersion: String
|
||||
}
|
||||
|
||||
public struct CoreVersionInfo: Decodable {
|
||||
@ -1737,6 +1748,7 @@ public enum AgentErrorType: Decodable {
|
||||
case SMP(smpErr: ProtocolErrorType)
|
||||
case NTF(ntfErr: ProtocolErrorType)
|
||||
case XFTP(xftpErr: XFTPErrorType)
|
||||
case RCP(rcpErr: RCErrorType)
|
||||
case BROKER(brokerAddress: String, brokerErr: BrokerErrorType)
|
||||
case AGENT(agentErr: SMPAgentError)
|
||||
case INTERNAL(internalErr: String)
|
||||
@ -1794,6 +1806,22 @@ public enum XFTPErrorType: Decodable {
|
||||
case INTERNAL
|
||||
}
|
||||
|
||||
public enum RCErrorType: Decodable {
|
||||
case `internal`(internalErr: String)
|
||||
case identity
|
||||
case noLocalAddress
|
||||
case tlsStartFailed
|
||||
case exception(exception: String)
|
||||
case ctrlAuth
|
||||
case ctrlNotFound
|
||||
case ctrlError(ctrlErr: String)
|
||||
case version
|
||||
case encrypt
|
||||
case decrypt
|
||||
case blockSize
|
||||
case syntax(syntaxErr: String)
|
||||
}
|
||||
|
||||
public enum ProtocolCommandError: Decodable {
|
||||
case UNKNOWN
|
||||
case SYNTAX
|
||||
@ -1831,12 +1859,12 @@ public enum ArchiveError: Decodable {
|
||||
}
|
||||
|
||||
public enum RemoteCtrlError: Decodable {
|
||||
case inactive
|
||||
case busy
|
||||
case timeout
|
||||
case disconnected(remoteCtrlId: Int64, reason: String)
|
||||
case connectionLost(remoteCtrlId: Int64, reason: String)
|
||||
case certificateExpired(remoteCtrlId: Int64)
|
||||
case certificateUntrusted(remoteCtrlId: Int64)
|
||||
case badFingerprint
|
||||
case inactive
|
||||
case badState
|
||||
case busy
|
||||
case timeout
|
||||
case disconnected(remoteCtrlId: Int64, reason: String)
|
||||
case badInvitation
|
||||
case badVersion(appVersion: String)
|
||||
// case protocolError(protocolError: RemoteProtocolError)
|
||||
}
|
||||
|
@ -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 & 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 |
@ -337,6 +337,7 @@
|
||||
"chat_recv_msg"
|
||||
"chat_recv_msg_wait"
|
||||
"chat_send_cmd"
|
||||
"chat_send_remote_cmd"
|
||||
"chat_valid_name"
|
||||
"chat_write_file"
|
||||
];
|
||||
@ -435,6 +436,7 @@
|
||||
"chat_recv_msg"
|
||||
"chat_recv_msg_wait"
|
||||
"chat_send_cmd"
|
||||
"chat_send_remote_cmd"
|
||||
"chat_valid_name"
|
||||
"chat_write_file"
|
||||
];
|
||||
|
@ -3,6 +3,7 @@ EXPORTS
|
||||
hs_init
|
||||
chat_migrate_init
|
||||
chat_send_cmd
|
||||
chat_send_remote_cmd
|
||||
chat_recv_msg
|
||||
chat_recv_msg_wait
|
||||
chat_parse_markdown
|
||||
|
Loading…
Reference in New Issue
Block a user