diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fe9032e7c..90e4272b0 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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) +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f47d39193..f1aba9126 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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 } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index a006f333f..1d8673320 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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() } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index bb88f5c38..741af6f08 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -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) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift new file mode 100644 index 000000000..0f6ef7be0 --- /dev/null +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -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) { + 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() +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 1cc859f49..423786eb6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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(_ 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) } } diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 51672d629..80e4adf2c 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -18,5 +18,7 @@ $(AppIdentifierPrefix)chat.simplex.app + com.apple.developer.networking.multicast + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e675772e7..62db4e43e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectDesktopView.swift; sourceTree = ""; }; 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrityErrorItemView.swift; sourceTree = ""; }; 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; @@ -397,6 +399,11 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CDA5A282B04FE2D00A71D61 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 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 = ""; }; + 5CDA5A2A2B04FE2D00A71D61 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CDA5A2B2B04FE2D00A71D61 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CDA5A2C2B04FE2D00A71D61 /* libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-rODxCBVsb2BkD1fnTAqXL.a"; sourceTree = ""; }; 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 = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -423,11 +430,6 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; - 5CF4DF722AFF8D4D007893ED /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 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 = ""; }; - 5CF4DF742AFF8D4D007893ED /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CF4DF752AFF8D4E007893ED /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CF4DF762AFF8D4E007893ED /* libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.3-EnhmkSQK6HvJ11g1uZERg8.a"; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 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 = ""; @@ -684,6 +687,14 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { + isa = PBXGroup; + children = ( + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */, + ); + path = RemoteAccess; + sourceTree = ""; + }; 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 */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b893d0045..e7409a072 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 351ed93c9..b729e3b7f 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -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)); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index f36c86c36..1b0d11a2b 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -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)); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 82201cce0..41deee7a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, val scaffoldState: ScaffoldState, - val switchingUsers: MutableState + val switchingUsersAndHosts: MutableState ) @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() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 91b4a8d8f..2de944f59 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -106,6 +106,11 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + // remote controller + val remoteHosts = mutableStateListOf() + val currentRemoteHost = mutableStateOf(null) + val newRemoteHostPairing = mutableStateOf?>(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() +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7896d2d6e..98c48dbfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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? { 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? { + 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? { 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): 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): 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, val agentMigrations: List): 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 2bed24b1f..c32137ee6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -15,6 +15,7 @@ external fun pipeStdOutToSocket(socketName: String) : Int typealias ChatCtrl = Long external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 42e43f7b7..2d526c513 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index fdf361bf6..095723a18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -590,7 +590,7 @@ private fun ShrinkItemAction(revealed: MutableState, 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 7af4d2670..7883a7a73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -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) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 8b65b2b5b..ecd47c937 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -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 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 8c7dc2c60..66cac7204 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -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, - switchingUsers: MutableState, + switchingUsersAndHosts: MutableState, 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, connecting: MutableState) { + 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) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 0f930b312..703b6f905 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -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 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt new file mode 100644 index 000000000..d00b9bb67 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -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, + remoteHosts: List, + connecting: MutableState, + connectedHost: State, + 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) { + 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) { + ModalManager.start.showModalCloseable { close -> + val invitation = rememberSaveable { mutableStateOf(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(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) { + ModalManager.start.showModalCloseable { close -> + val pairing = remember { chatModel.newRemoteHostPairing } + val invitation = rememberSaveable { mutableStateOf(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(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(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() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index d949f800b..5fa3c4147 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -29,6 +29,7 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.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() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 170a28f3d..9889d6ab7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1625,6 +1625,24 @@ You can enable them later via app Privacy & Security settings. Error enabling delivery receipts! + + Link a mobile + Linked mobiles + Scan from mobile + Verify connection + Verify code on mobile + This device name + Connected mobile + Connected to mobile + Enter this device name… + The device name will be shared with the connected mobile client. + Error + This device + Devices + New mobile device + Disconnect + Use from desktop in mobile app and scan QR code]]> + Coming soon! This feature is not yet supported. Try the next release. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg new file mode 100644 index 000000000..93094d144 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg new file mode 100644 index 000000000..7d8553db1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg new file mode 100644 index 000000000..2fb4750af --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg new file mode 100644 index 000000000..814077e48 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flake.nix b/flake.nix index 3ef1913fd..14b7ae731 100644 --- a/flake.nix +++ b/flake.nix @@ -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" ]; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 755119fca..2d6e813d7 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -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