diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cddc47403..a80156055 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,11 +263,38 @@ jobs: # * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753 # * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065 + - name: 'Setup MSYS2' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: ucrt64 + update: true + install: >- + git + perl + make + pacboy: >- + toolchain:p + cmake:p + + - name: Windows build id: windows_build if: matrix.os == 'windows-latest' - shell: cmd + shell: msys2 {0} run: | + export PATH=$PATH:/c/ghcup/bin + scripts/desktop/prepare-openssl-windows.sh + openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') + rm cabal.project.local 2>/dev/null || true + echo "ignore-project: False" >> cabal.project.local + echo "package direct-sqlcipher" >> cabal.project.local + echo " flags: +openssl" >> cabal.project.local + echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local + echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + + rm -rf dist-newstyle/src/direct-sq* + sed -i "s/, unix /--, unix /" simplex-chat.cabal cabal build --enable-tests rm -rf dist-newstyle/src/direct-sq* path=$(cabal list-bin simplex-chat | tail -n 1) @@ -293,20 +320,6 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} - - name: 'Setup MSYS2' - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' - uses: msys2/setup-msys2@v2 - with: - msystem: ucrt64 - update: true - install: >- - git - perl - make - pacboy: >- - toolchain:p - cmake:p - - name: Windows build desktop id: windows_desktop_build if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' 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 ec539b7fa..9e4cc7cd0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -905,6 +905,44 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? { } } +func setLocalDeviceName(_ displayName: String) throws { + try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) +} + +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 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 stopRemoteCtrl() async throws { + try await sendCommandOkResp(.stopRemoteCtrl) +} + +func deleteRemoteCtrl(_ rcId: Int64) async throws { + try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) +} + func networkErrorAlert(_ r: ChatResponse) -> Alert? { switch r { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): @@ -1033,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)) @@ -1670,6 +1714,26 @@ 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: + // This delay is needed to cancel the session that fails on network failure, + // e.g. when user did not grant permission to access local network yet. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + switchToLocalSession() + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -1683,6 +1747,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..c78a7cb94 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -18,5 +18,9 @@ $(AppIdentifierPrefix)chat.simplex.app + com.apple.developer.networking.multicast + + com.apple.developer.device-information.user-assigned-device-name + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 40be34750..d8fb52ca3 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 */; }; @@ -287,6 +288,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 = ""; }; @@ -402,6 +404,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 = ""; }; @@ -428,11 +435,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; }; @@ -515,13 +517,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; }; @@ -554,6 +556,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -582,11 +585,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 = ""; @@ -694,6 +697,14 @@ path = "Tests iOS"; sourceTree = ""; }; + 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { + isa = PBXGroup; + children = ( + 5C3CCFCB2AE6BD3100C3F0C3 /* ConnectDesktopView.swift */, + ); + path = RemoteAccess; + sourceTree = ""; + }; 5CB0BA8C282711BC00B3292C /* Onboarding */ = { isa = PBXGroup; children = ( @@ -1180,6 +1191,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 */, @@ -1500,6 +1512,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1542,6 +1555,7 @@ INFOPLIST_FILE = "SimpleX--iOS--Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls."; INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "SimpleX uses local network access to allow using user chat profile via desktop app on the same network."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls, and to record voice messages."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c19e65c9d..e7409a072 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -120,6 +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 connectRemoteCtrl(xrcpInvitation: String) + case findKnownRemoteCtrl + case confirmRemoteCtrl(remoteCtrlId: Int64) + case verifyRemoteCtrlSession(sessionCode: String) + case listRemoteCtrls + case stopRemoteCtrl + case deleteRemoteCtrl(remoteCtrlId: Int64) + // misc case showVersion case string(String) @@ -260,6 +270,14 @@ public enum ChatCommand { case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" 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 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)" case .showVersion: return "/version" case let .string(str): return str } @@ -375,6 +393,14 @@ public enum ChatCommand { case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" case .cancelFile: return "cancelFile" + case .setLocalDeviceName: return "setLocalDeviceName" + case .connectRemoteCtrl: return "connectRemoteCtrl" + case .findKnownRemoteCtrl: return "findKnownRemoteCtrl" + case .confirmRemoteCtrl: return "confirmRemoteCtrl" + case .verifyRemoteCtrlSession: return "verifyRemoteCtrlSession" + case .listRemoteCtrls: return "listRemoteCtrls" + case .stopRemoteCtrl: return "stopRemoteCtrl" + case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .showVersion: return "showVersion" case .string: return "console command" } @@ -581,6 +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 remoteCtrlFound(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) @@ -720,6 +754,12 @@ public enum ChatResponse: Decodable, Error { case .ntfMessages: return "ntfMessages" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" + case .remoteCtrlList: return "remoteCtrlList" + case .remoteCtrlFound: return "remoteCtrlFound" + case .remoteCtrlConnecting: return "remoteCtrlConnecting" + case .remoteCtrlSessionCode: return "remoteCtrlSessionCode" + case .remoteCtrlConnected: return "remoteCtrlConnected" + case .remoteCtrlStopped: return "remoteCtrlStopped" case .versionInfo: return "versionInfo" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" @@ -862,6 +902,12 @@ public enum ChatResponse: Decodable, Error { case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") 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 .remoteCtrlFound(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))" case .cmdOk: return noDetails case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) @@ -1488,6 +1534,33 @@ public enum NotificationPreviewMode: String, SelectableItem { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } +public struct RemoteCtrlInfo: Decodable { + public var remoteCtrlId: Int64 + public var ctrlDeviceName: String + public var sessionState: RemoteCtrlSessionState? + + public var deviceViewName: String { + ctrlDeviceName == "" ? "\(remoteCtrlId)" : ctrlDeviceName + } +} + +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 { public var version: String public var simplexmqVersion: String @@ -1515,6 +1588,7 @@ public enum ChatError: Decodable { case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) case invalidJSON(json: String) } @@ -1674,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) @@ -1731,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 @@ -1766,3 +1857,14 @@ public enum ArchiveError: Decodable { case `import`(chatError: ChatError) case importFile(file: String, chatError: ChatError) } + +public enum RemoteCtrlError: Decodable { + 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/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 459b1bc05..09b33316a 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -15,7 +15,6 @@ - diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 40c04f508..4c5d595a8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -126,7 +126,7 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel) + if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId, uri.toURI(), ChatModel) } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index d2c446517..13908f69b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -57,7 +57,7 @@ class SimplexApp: Application(), LifecycleEventObserver { updatingChatsMutex.withLock { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) + val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId)) /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ if (chatModel.currentUser.value?.userId == currentUserId) { val currentChatId = chatModel.chatId.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index 192f3dcc2..90b18bde9 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -16,6 +16,8 @@ import kotlin.random.Random actual val appPlatform = AppPlatform.ANDROID +actual val deviceName = android.os.Build.MODEL + var isAppOnForeground: Boolean = false @Suppress("ConstantLocale") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 161bc51e6..dfc8c1d4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir +actual val remoteHostsDir: File = File(tmpDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() {} @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index f99dea77c..5b0d3c778 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -38,7 +38,7 @@ actual class RecorderNative: RecorderInterface { rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir) + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) fileToSave.deleteOnExit() val path = fileToSave.absolutePath filePath = path diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 5c7b430ab..c173463d5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -115,22 +115,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -139,7 +140,7 @@ actual fun ActiveCallView() { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt deleted file mode 100644 index 79361dc07..000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt +++ /dev/null @@ -1,16 +0,0 @@ -package chat.simplex.common.views.chat - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.views.chat.ScanCodeLayout -import com.google.accompanist.permissions.rememberPermissionState - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanCodeLayout(verifyCode, close) -} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index f2c2f393a..d24429476 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -167,7 +167,7 @@ actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -actual fun getLoadedImage(file: CIFile?): Pair? { +actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) return if (filePath != null && file != null) { try { @@ -295,7 +295,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return try { val ext = if (asPng) "png" else "jpg" tmpDir.mkdir() - return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { + return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext, tmpDir)).apply { outputStream().use { out -> image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) out.flush() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index dcb505542..1faf115b3 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -12,7 +12,8 @@ import chat.simplex.common.model.ChatModel import chat.simplex.res.MR @Composable -actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model val selection = remember { mutableStateOf( runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN) @@ -31,10 +32,10 @@ actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { Column(Modifier.weight(1f)) { when (selection.value) { ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, close) + ScanToConnectView(m, rhId, close) } ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, close) + PasteToConnectView(m, rhId, close) } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 7fb6445d5..e7453ce20 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import android.Manifest import android.annotation.SuppressLint import android.util.Log import android.view.ViewGroup @@ -19,6 +20,7 @@ import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG +import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.* @@ -26,6 +28,10 @@ import java.util.concurrent.* @Composable actual fun QRCodeScanner(onBarcode: (String) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index d0cad3121..89477e45a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -7,13 +7,14 @@ import chat.simplex.common.model.ChatModel import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt index a1b7b3141..af5a27be1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt @@ -7,10 +7,10 @@ import chat.simplex.common.model.ServerCfg import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() } - ScanProtocolServerLayout(onNext) + ScanProtocolServerLayout(rhId, onNext) } 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..da74a37aa 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 { @@ -138,7 +138,7 @@ fun MainScreen() { } onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) - onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) + onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } if (appPlatform.isAndroid) { @@ -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 efd7ced3a..ab8b6af3f 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 @@ -1,7 +1,8 @@ package chat.simplex.common.model -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* @@ -26,6 +27,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File import java.net.URI +import java.net.URLDecoder import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -106,6 +108,13 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + // remote controller + val remoteHosts = mutableStateListOf() + val currentRemoteHost = mutableStateOf(null) + val remoteHostId: Long? get() = currentRemoteHost?.value?.remoteHostId + val newRemoteHostPairing = mutableStateOf?>(null) + val remoteCtrlSession = mutableStateOf(null) + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -133,16 +142,17 @@ object ChatModel { } // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null + fun hasChat(rhId: Long?, id: String): Boolean = chats.toList().firstOrNull { it.id == id && it.remoteHostId == rhId } != null + // TODO pass rhId? fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } - private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.toList().indexOfFirst { it.id == id && it.remoteHostId == rhId } fun addChat(chat: Chat) = chats.add(index = 0, chat) - fun updateChatInfo(cInfo: ChatInfo) { - val i = getChatIndex(cInfo.id) + fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { val currentCInfo = chats[i].chatInfo var newCInfo = cInfo @@ -164,23 +174,23 @@ object ChatModel { } } - fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) + fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) - fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) - fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { + fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) { val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) val updatedContact = contact.copy(activeConn = updatedConn) - updateContact(updatedContact) + updateContact(rhId, updatedContact) } - fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) + fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) - private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { - if (hasChat(cInfo.id)) { - updateChatInfo(cInfo) + private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { + if (hasChat(rhId, cInfo.id)) { + updateChatInfo(rhId, cInfo) } else if (addMissing) { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf())) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) } } @@ -195,8 +205,8 @@ object ChatModel { } } - fun replaceChat(id: String, chat: Chat) { - val i = getChatIndex(id) + fun replaceChat(rhId: Long?, id: String, chat: Chat) { + val i = getChatIndex(rhId, id) if (i >= 0) { chats[i] = chat } else { @@ -205,9 +215,9 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -216,7 +226,7 @@ object ChatModel { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId - increaseUnreadCounter(currentUser.value!!) + increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId) } else @@ -226,7 +236,7 @@ object ChatModel { popChat_(i) } } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { @@ -246,9 +256,9 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat val res: Boolean if (i >= 0) { @@ -258,12 +268,12 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } } res = false } else { - addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") @@ -305,12 +315,12 @@ object ChatModel { } } - fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) { + fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(cInfo.id) + decreaseCounterInChat(rhId, cInfo.id) } // update previews - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { chat = chats[i] @@ -329,11 +339,11 @@ object ChatModel { } } - fun clearChat(cInfo: ChatInfo) { + fun clearChat(rhId: Long?, cInfo: ChatInfo) { // clear preview - val i = getChatIndex(cInfo.id) + val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { - decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) } // clear current chat @@ -343,15 +353,15 @@ object ChatModel { } } - fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) { + fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( profile = newProfile.toLocalProfile(current.profile.profileId), fullPreferences = preferences ?: current.fullPreferences ) - val indexInUsers = users.indexOfFirst { it.user.userId == current.userId } - if (indexInUsers != -1) { - users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) + val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(user = updated) } currentUser.value = updated } @@ -370,16 +380,17 @@ object ChatModel { } } - fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { - val markedRead = markItemsReadInCurrentChat(cInfo, range) + fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { + val cInfo = chat.chatInfo + val markedRead = markItemsReadInCurrentChat(chat, range) // update preview - val chatIdx = getChatIndex(cInfo.id) + val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -391,7 +402,8 @@ object ChatModel { } } - private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int { + private fun markItemsReadInCurrentChat(chat: Chat, range: CC.ItemRange? = null): Int { + val cInfo = chat.chatInfo var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 @@ -415,13 +427,13 @@ object ChatModel { return markedRead } - private fun decreaseCounterInChat(chatId: ChatId) { - val chatIndex = getChatIndex(chatId) + private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) - decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, @@ -429,18 +441,18 @@ object ChatModel { ) } - fun increaseUnreadCounter(user: UserLike) { - changeUnreadCounter(user, 1) + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounter(rhId, user, 1) } - fun decreaseUnreadCounter(user: UserLike, by: Int = 1) { - changeUnreadCounter(user, -by) + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounter(rhId, user, -by) } - private fun changeUnreadCounter(user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId } + private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { - users[i] = UserInfo(users[i].user, users[i].unreadCount + by) + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) } } @@ -536,14 +548,14 @@ object ChatModel { } } - fun removeChat(id: String) { - chats.removeAll { it.id == id } + fun removeChat(rhId: Long?, id: String) { + chats.removeAll { it.id == id && it.remoteHostId == rhId } } - fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean { + fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { // user member was updated if (groupInfo.membership.groupMemberId == member.groupMemberId) { - updateGroup(groupInfo) + updateGroup(rhId, groupInfo) return false } // update current chat @@ -561,12 +573,12 @@ object ChatModel { } } - fun updateGroupMemberConnectionStats(groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { val memberConn = member.activeConn if (memberConn != null) { val updatedConn = memberConn.copy(connectionStats = connectionStats) val updatedMember = member.copy(activeConn = updatedConn) - upsertGroupMember(groupInfo, updatedMember) + upsertGroupMember(rhId, groupInfo, updatedMember) } } @@ -591,6 +603,8 @@ object ChatModel { } terminalItems.add(item) } + + fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } enum class ChatType(val type: String) { @@ -602,6 +616,7 @@ enum class ChatType(val type: String) { @Serializable data class User( + val remoteHostId: Long? = null, override val userId: Long, val userContactId: Long, val localDisplayName: String, @@ -701,9 +716,10 @@ interface SomeChat { @Serializable @Stable data class Chat ( + val remoteHostId: Long? = null, val chatInfo: ChatInfo, val chatItems: List, - val chatStats: ChatStats = ChatStats(), + val chatStats: ChatStats = ChatStats() ) { val userCanSend: Boolean get() = when (chatInfo) { @@ -2219,7 +2235,7 @@ enum class MREmojiChar(val value: String) { } @Serializable -class CIFile( +data class CIFile( val fileId: Long, val fileName: String, val fileSize: Long, @@ -2263,6 +2279,39 @@ class CIFile( is CIFileStatus.Invalid -> null } + /** + * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking]. + * Otherwise, it will be canceled when moving to another screen/item/view, etc + * */ + suspend fun loadRemoteFile(allowToShowAlert: Boolean): Boolean { + val rh = chatModel.currentRemoteHost.value + val user = chatModel.currentUser.value + if (rh == null || user == null || fileSource == null || !loaded) return false + if (getLoadedFilePath(this) != null) return true + if (cachedRemoteFileRequests.contains(fileSource)) return false + + val rf = RemoteFile( + userId = user.userId, + fileId = fileId, + sent = fileStatus.sent, + fileSource = fileSource + ) + cachedRemoteFileRequests.add(fileSource) + val showAlert = fileSize > 5_000_000 && allowToShowAlert + if (showAlert) { + AlertManager.shared.showAlertMsgWithProgress( + title = generalGetString(MR.strings.loading_remote_file_title), + text = generalGetString(MR.strings.loading_remote_file_desc) + ) + } + val res = chatModel.controller.getRemoteFile(rh.remoteHostId, rf) + cachedRemoteFileRequests.remove(fileSource) + if (showAlert) { + AlertManager.shared.hideAlert() + } + return res + } + companion object { fun getSample( fileId: Long = 1, @@ -2272,6 +2321,8 @@ class CIFile( fileStatus: CIFileStatus = CIFileStatus.RcvComplete ): CIFile = CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP) + + val cachedRemoteFileRequests = SnapshotStateList() } } @@ -2303,6 +2354,8 @@ data class CryptoFile( companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) + + fun desktopPlain(f: URI): CryptoFile = CryptoFile(URLDecoder.decode(f.rawPath, "UTF-8"), null) } } @@ -2365,6 +2418,21 @@ sealed class CIFileStatus { @Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus() @Serializable @SerialName("rcvError") object RcvError: CIFileStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus() + + val sent: Boolean get() = when (this) { + is SndStored -> true + is SndTransfer -> true + is SndComplete -> true + is SndCancelled -> true + is SndError -> true + is RcvInvitation -> false + is RcvAccepted -> false + is RcvTransfer -> false + is RcvComplete -> false + is RcvCancelled -> false + is RcvError -> false + is Invalid -> false + } } @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @@ -2843,3 +2911,34 @@ enum class NotificationPreviewMode { val default: NotificationPreviewMode = MESSAGE } } + +data class RemoteCtrlSession( + val ctrlAppInfo: CtrlAppInfo, + val appVersion: String, + val sessionState: UIRemoteCtrlSessionState +) { + val active: Boolean + get () = sessionState is UIRemoteCtrlSessionState.Connected + + val sessionCode: String? + get() = when (val s = sessionState) { + is UIRemoteCtrlSessionState.PendingConfirmation -> s.sessionCode + is UIRemoteCtrlSessionState.Connected -> s.sessionCode + else -> null + } +} + +@Serializable +sealed class RemoteCtrlSessionState { + @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() +} + +sealed class UIRemoteCtrlSessionState { + @Serializable @SerialName("starting") object Starting: UIRemoteCtrlSessionState() + @Serializable @SerialName("connecting") data class Connecting(val remoteCtrl_: RemoteCtrlInfo? = null): UIRemoteCtrlSessionState() + @Serializable @SerialName("pendingConfirmation") data class PendingConfirmation(val remoteCtrl_: RemoteCtrlInfo? = null, val sessionCode: String): UIRemoteCtrlSessionState() + @Serializable @SerialName("connected") data class Connected(val remoteCtrl: RemoteCtrlInfo, val sessionCode: String): UIRemoteCtrlSessionState() +} 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 1fa8c35d5..ffb5b4251 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 @@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatModel.remoteHostId import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* @@ -166,6 +167,11 @@ class AppPreferences { val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) val customDisappearingMessageTime = mkIntPreference(SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME, 300) + val deviceNameForRemoteAccess = mkStrPreference(SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS, deviceName) + + val confirmRemoteSessions = mkBoolPreference(SHARED_PREFS_CONFIRM_REMOTE_SESSIONS, false) + val connectRemoteViaMulticast = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST, false) + val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -306,6 +312,10 @@ class AppPreferences { private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion" private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode" private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime" + private const val SHARED_PREFS_DEVICE_NAME_FOR_REMOTE_ACCESS = "DeviceNameForRemoteAccess" + private const val SHARED_PREFS_CONFIRM_REMOTE_SESSIONS = "ConfirmRemoteSessions" + private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" + private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" } } @@ -337,23 +347,27 @@ object ChatController { apiSetNetworkConfig(getNetCfg()) apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } apiSetXFTPConfig(getXFTPCfg()) apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() - val users = listUsers() + val users = listUsers(null) chatModel.users.clear() chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true - getUserChatData() + getUserChatData(null) appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() + setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!) Log.d(TAG, "startChat: started") } else { updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(null) chatModel.updateChats(chats) } Log.d(TAG, "startChat: running") @@ -364,33 +378,33 @@ object ChatController { } } - suspend fun changeActiveUser(toUserId: Long, viewPwd: String?) { + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { - changeActiveUser_(toUserId, viewPwd) + changeActiveUser_(rhId, toUserId, viewPwd) } catch (e: Exception) { Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_active_user_title), e.stackTraceToString()) } } - suspend fun changeActiveUser_(toUserId: Long, viewPwd: String?) { - val currentUser = apiSetActiveUser(toUserId, viewPwd) + suspend fun changeActiveUser_(rhId: Long?, toUserId: Long, viewPwd: String?) { + val currentUser = apiSetActiveUser(rhId, toUserId, viewPwd) chatModel.currentUser.value = currentUser - val users = listUsers() + val users = listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - getUserChatData() + getUserChatData(rhId) val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) } } - suspend fun getUserChatData() { - chatModel.userAddress.value = apiGetUserAddress() - chatModel.chatItemTTL.value = getChatItemTTL() + suspend fun getUserChatData(rhId: Long?) { + chatModel.userAddress.value = apiGetUserAddress(rhId) + chatModel.chatItemTTL.value = getChatItemTTL(rhId) updatingChatsMutex.withLock { - val chats = apiGetChats() + val chats = apiGetChats(rhId) chatModel.updateChats(chats) } } @@ -415,46 +429,47 @@ object ChatController { } } - suspend fun sendCmd(cmd: CC): CR { + suspend fun sendCmd(rhId: Long?, cmd: CC): CR { val ctrl = ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.addTerminalItem(TerminalItem.cmd(cmd.obfuscated)) + chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) Log.d(TAG, "sendCmd: ${cmd.cmdType}") - val json = chatSendCmd(ctrl, c) + val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), 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) { Log.d(TAG, "sendCmd response json $json") } - chatModel.addTerminalItem(TerminalItem.resp(r.resp)) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp)) r.resp } } - private fun recvMsg(ctrl: ChatCtrl): CR? { + private fun recvMsg(ctrl: ChatCtrl): APIResponse? { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (json == "") { null } else { - val r = APIResponse.decodeStr(json).resp + val apiResp = APIResponse.decodeStr(json) + val r = apiResp.resp Log.d(TAG, "chatRecvMsg: ${r.responseType}") if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json") - r + apiResp } } - suspend fun apiGetActiveUser(): User? { - val r = sendCmd(CC.ShowActiveUser()) + suspend fun apiGetActiveUser(rh: Long?): User? { + val r = sendCmd(rh, CC.ShowActiveUser()) if (r is CR.ActiveUser) return r.user Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") chatModel.userCreated.value = false return null } - suspend fun apiCreateActiveUser(p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { - val r = sendCmd(CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { + val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) if (r is CR.ActiveUser) return r.user else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -468,65 +483,68 @@ object ChatController { return null } - suspend fun listUsers(): List { - val r = sendCmd(CC.ListUsers()) - if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName } + suspend fun listUsers(rh: Long?): List { + val r = sendCmd(rh, CC.ListUsers()) + if (r is CR.UsersList) { + val users = if (rh == null) r.users else r.users.map { it.copy(user = it.user.copy(remoteHostId = rh)) } + return users.sortedBy { it.user.chatViewName } + } Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") throw Exception("failed to list users ${r.responseType} ${r.details}") } - suspend fun apiSetActiveUser(userId: Long, viewPwd: String?): User { - val r = sendCmd(CC.ApiSetActiveUser(userId, viewPwd)) - if (r is CR.ActiveUser) return r.user + suspend fun apiSetActiveUser(rh: Long?, userId: Long, viewPwd: String?): User { + val r = sendCmd(rh, CC.ApiSetActiveUser(userId, viewPwd)) + if (r is CR.ActiveUser) return if (rh == null) r.user else r.user.copy(remoteHostId = rh) Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") throw Exception("failed to set the user as active ${r.responseType} ${r.details}") } - suspend fun apiSetAllContactReceipts(enable: Boolean) { - val r = sendCmd(CC.SetAllContactReceipts(enable)) + suspend fun apiSetAllContactReceipts(rh: Long?, enable: Boolean) { + val r = sendCmd(rh, CC.SetAllContactReceipts(enable)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}") } - suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserContactReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserContactReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}") } - suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) { - val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings)) + suspend fun apiSetUserGroupReceipts(u: User, userMsgReceiptSettings: UserMsgReceiptSettings) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserGroupReceipts(u.userId, userMsgReceiptSettings)) if (r is CR.CmdOk) return throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}") } - suspend fun apiHideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiHideUser(userId, viewPwd)) + suspend fun apiHideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) - suspend fun apiUnhideUser(userId: Long, viewPwd: String): User = - setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd)) + suspend fun apiUnhideUser(u: User, viewPwd: String): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnhideUser(u.userId, viewPwd)) - suspend fun apiMuteUser(userId: Long): User = - setUserPrivacy(CC.ApiMuteUser(userId)) + suspend fun apiMuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiMuteUser(u.userId)) - suspend fun apiUnmuteUser(userId: Long): User = - setUserPrivacy(CC.ApiUnmuteUser(userId)) + suspend fun apiUnmuteUser(u: User): User = + setUserPrivacy(u.remoteHostId, CC.ApiUnmuteUser(u.userId)) - private suspend fun setUserPrivacy(cmd: CC): User { - val r = sendCmd(cmd) - if (r is CR.UserPrivacy) return r.updatedUser + private suspend fun setUserPrivacy(rh: Long?, cmd: CC): User { + val r = sendCmd(rh, cmd) + if (r is CR.UserPrivacy) return if (rh == null) r.updatedUser else r.updatedUser.copy(remoteHostId = rh) else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } - suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean, viewPwd: String?) { - val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues, viewPwd)) + suspend fun apiDeleteUser(u: User, delSMPQueues: Boolean, viewPwd: String?) { + val r = sendCmd(u.remoteHostId, CC.ApiDeleteUser(u.userId, delSMPQueues, viewPwd)) if (r is CR.CmdOk) return Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") throw Exception("failed to delete the user ${r.responseType} ${r.details}") } suspend fun apiStartChat(): Boolean { - val r = sendCmd(CC.StartChat(expire = true)) + val r = sendCmd(null, CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -535,7 +553,7 @@ object ChatController { } suspend fun apiStopChat(): Boolean { - val r = sendCmd(CC.ApiStopChat()) + val r = sendCmd(null, CC.ApiStopChat()) when (r) { is CR.ChatStopped -> return true else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") @@ -543,70 +561,76 @@ object ChatController { } private suspend fun apiSetTempFolder(tempFolder: String) { - val r = sendCmd(CC.SetTempFolder(tempFolder)) + val r = sendCmd(null, CC.SetTempFolder(tempFolder)) if (r is CR.CmdOk) return throw Error("failed to set temp folder: ${r.responseType} ${r.details}") } private suspend fun apiSetFilesFolder(filesFolder: String) { - val r = sendCmd(CC.SetFilesFolder(filesFolder)) + val r = sendCmd(null, CC.SetFilesFolder(filesFolder)) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + private suspend fun apiSetRemoteHostsFolder(remoteHostsFolder: String) { + val r = sendCmd(null, CC.SetRemoteHostsFolder(remoteHostsFolder)) + if (r is CR.CmdOk) return + throw Error("failed to set remote hosts folder: ${r.responseType} ${r.details}") + } + suspend fun apiSetXFTPConfig(cfg: XFTPFileConfig?) { - val r = sendCmd(CC.ApiSetXFTPConfig(cfg)) + val r = sendCmd(null, CC.ApiSetXFTPConfig(cfg)) if (r is CR.CmdOk) return throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } - suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) suspend fun apiExportArchive(config: ArchiveConfig) { - val r = sendCmd(CC.ApiExportArchive(config)) + val r = sendCmd(null, CC.ApiExportArchive(config)) if (r is CR.CmdOk) return throw Error("failed to export archive: ${r.responseType} ${r.details}") } suspend fun apiImportArchive(config: ArchiveConfig): List { - val r = sendCmd(CC.ApiImportArchive(config)) + val r = sendCmd(null, CC.ApiImportArchive(config)) if (r is CR.ArchiveImported) return r.archiveErrors throw Error("failed to import archive: ${r.responseType} ${r.details}") } suspend fun apiDeleteStorage() { - val r = sendCmd(CC.ApiDeleteStorage()) + val r = sendCmd(null, CC.ApiDeleteStorage()) if (r is CR.CmdOk) return throw Error("failed to delete storage: ${r.responseType} ${r.details}") } suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? { - val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) + val r = sendCmd(null, CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey))) if (r is CR.CmdOk) return null else if (r is CR.ChatCmdError) return r throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun apiGetChats(): List { + suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } - val r = sendCmd(CC.ApiGetChats(userId)) - if (r is CR.ApiChats) return r.chats + val r = sendCmd(rh, CC.ApiGetChats(userId)) + if (r is CR.ApiChats) return if (rh == null) r.chats else r.chats.map { it.copy(remoteHostId = rh) } Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chats_title), generalGetString(MR.strings.contact_developers)) return emptyList() } - suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { - val r = sendCmd(CC.ApiGetChat(type, id, pagination, search)) - if (r is CR.ApiChat) return r.chat + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + if (r is CR.ApiChat) return if (rh == null) r.chat else r.chat.copy(remoteHostId = rh) Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_parse_chat_title), generalGetString(MR.strings.contact_developers)) return null } - suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { + suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) - val r = sendCmd(cmd) + val r = sendCmd(rh, cmd) return when (r) { is CR.NewChatItem -> r.chatItem else -> { @@ -618,8 +642,8 @@ object ChatController { } } - suspend fun apiGetChatItemInfo(type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(CC.ApiGetChatItemInfo(type, id, itemId))) { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { + return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo else -> { apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) @@ -628,38 +652,38 @@ object ChatController { } } - suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { - val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatItemReaction(type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { - val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode)) + suspend fun apiDeleteChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): CR.ChatItemDeleted? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemId, mode)) if (r is CR.ChatItemDeleted) return r Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteMemberChatItem(groupId: Long, groupMemberId: Long, itemId: Long): Pair? { - val r = sendCmd(CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) + suspend fun apiDeleteMemberChatItem(rh: Long?, groupId: Long, groupMemberId: Long, itemId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) if (r is CR.ChatItemDeleted) return r.deletedChatItem.chatItem to r.toChatItem?.chatItem Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun getUserProtoServers(serverProtocol: ServerProtocol): UserProtocolServers? { + suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) r.servers + val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) + return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } else { Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( @@ -670,9 +694,9 @@ object ChatController { } } - suspend fun setUserProtoServers(serverProtocol: ServerProtocol, servers: List): Boolean { + suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(CC.APISetUserProtoServers(userId, serverProtocol, servers)) + val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) return when (r) { is CR.CmdOk -> true else -> { @@ -686,9 +710,9 @@ object ChatController { } } - suspend fun testProtoServer(server: String): ProtocolTestFailure? { + suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") - val r = sendCmd(CC.APITestProtoServer(userId, server)) + val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) return when (r) { is CR.ServerTestResult -> r.testFailure else -> { @@ -698,29 +722,22 @@ object ChatController { } } - suspend fun getChatItemTTL(): ChatItemTTL { + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") - val r = sendCmd(CC.APIGetChatItemTTL(userId)) + val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } - suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) { + suspend fun setChatItemTTL(rh: Long?, chatItemTTL: ChatItemTTL) { val userId = currentUserId("setChatItemTTL") - val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) + val r = sendCmd(rh, CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) if (r is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiGetNetworkConfig(): NetCfg? { - val r = sendCmd(CC.APIGetNetworkConfig()) - if (r is CR.NetworkConfig) return r.networkConfig - Log.e(TAG, "apiGetNetworkConfig bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean { - val r = sendCmd(CC.APISetNetworkConfig(cfg)) + val r = sendCmd(null, CC.APISetNetworkConfig(cfg)) return when (r) { is CR.CmdOk -> true else -> { @@ -734,8 +751,8 @@ object ChatController { } } - suspend fun apiSetSettings(type: ChatType, id: Long, settings: ChatSettings): Boolean { - val r = sendCmd(CC.APISetChatSettings(type, id, settings)) + suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean { + val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings)) return when (r) { is CR.CmdOk -> true else -> { @@ -745,88 +762,88 @@ object ChatController { } } - suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = - sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) - suspend fun apiContactInfo(contactId: Long): Pair? { - val r = sendCmd(CC.APIContactInfo(contactId)) + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats_ to r.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) + suspend fun apiGroupMemberInfo(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGroupMemberInfo(groupId, groupMemberId)) if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APISwitchContact(contactId)) + suspend fun apiSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APISwitchContact(contactId)) if (r is CR.ContactSwitchStarted) return r.connectionStats apiErrorAlert("apiSwitchContact", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId)) + suspend fun apiSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APISwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } - suspend fun apiAbortSwitchContact(contactId: Long): ConnectionStats? { - val r = sendCmd(CC.APIAbortSwitchContact(contactId)) + suspend fun apiAbortSwitchContact(rh: Long?, contactId: Long): ConnectionStats? { + val r = sendCmd(rh, CC.APIAbortSwitchContact(contactId)) if (r is CR.ContactSwitchAborted) return r.connectionStats apiErrorAlert("apiAbortSwitchContact", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) + suspend fun apiAbortSwitchGroupMember(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } - suspend fun apiSyncContactRatchet(contactId: Long, force: Boolean): ConnectionStats? { - val r = sendCmd(CC.APISyncContactRatchet(contactId, force)) + suspend fun apiSyncContactRatchet(rh: Long?, contactId: Long, force: Boolean): ConnectionStats? { + val r = sendCmd(rh, CC.APISyncContactRatchet(contactId, force)) if (r is CR.ContactRatchetSyncStarted) return r.connectionStats apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiSyncGroupMemberRatchet(groupId: Long, groupMemberId: Long, force: Boolean): Pair? { - val r = sendCmd(CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) + suspend fun apiSyncGroupMemberRatchet(rh: Long?, groupId: Long, groupMemberId: Long, force: Boolean): Pair? { + val r = sendCmd(rh, CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) return null } - suspend fun apiGetContactCode(contactId: Long): Pair? { - val r = sendCmd(CC.APIGetContactCode(contactId)) + suspend fun apiGetContactCode(rh: Long?, contactId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode Log.e(TAG,"failed to get contact code: ${r.responseType} ${r.details}") return null } - suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair? { - val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId)) + suspend fun apiGetGroupMemberCode(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.APIGetGroupMemberCode(groupId, groupMemberId)) if (r is CR.GroupMemberCode) return r.member to r.connectionCode Log.e(TAG,"failed to get group member code: ${r.responseType} ${r.details}") return null } - suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) { + suspend fun apiVerifyContact(rh: Long?, contactId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyContact(contactId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } } - suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { - return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { + suspend fun apiVerifyGroupMember(rh: Long?, groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(rh, CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { is CR.ConnectionVerified -> r.verified to r.expectedCode else -> null } @@ -834,12 +851,12 @@ object ChatController { - suspend fun apiAddContact(incognito: Boolean): Pair? { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiAddContact: no current user") return null } - val r = sendCmd(CC.APIAddContact(userId, incognito)) + val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) return when (r) { is CR.Invitation -> r.connReqInvitation to r.connection else -> { @@ -851,27 +868,27 @@ object ChatController { } } - suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito)) + suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) if (r is CR.ConnectionIncognitoUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + val r = sendCmd(rh, CC.APIConnectPlan(userId, connReq)) if (r is CR.CRConnectionPlan) return r.connectionPlan Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") return false } - val r = sendCmd(CC.APIConnect(userId, incognito, connReq)) + val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -907,12 +924,12 @@ object ChatController { } } - suspend fun apiConnectContactViaAddress(incognito: Boolean, contactId: Long): Contact? { + suspend fun apiConnectContactViaAddress(rh: Long?, incognito: Boolean, contactId: Long): Contact? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnectContactViaAddress: no current user") return null } - val r = sendCmd(CC.ApiConnectContactViaAddress(userId, incognito, contactId)) + val r = sendCmd(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId)) when { r is CR.SentInvitationToContact -> return r.contact else -> { @@ -924,8 +941,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -943,24 +960,16 @@ object ChatController { return false } - suspend fun apiClearChat(type: ChatType, id: Long): ChatInfo? { - val r = sendCmd(CC.ApiClearChat(type, id)) + suspend fun apiClearChat(rh: Long?, type: ChatType, id: Long): ChatInfo? { + val r = sendCmd(rh, CC.ApiClearChat(type, id)) if (r is CR.ChatCleared) return r.chatInfo Log.e(TAG, "apiClearChat bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListContacts(): List? { - val userId = kotlin.runCatching { currentUserId("apiListContacts") }.getOrElse { return null } - val r = sendCmd(CC.ApiListContacts(userId)) - if (r is CR.ContactsList) return r.contacts - Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") - return null - } - - suspend fun apiUpdateProfile(profile: Profile): Pair>? { + suspend fun apiUpdateProfile(rh: Long?, profile: Profile): Pair>? { val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null } - val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) + val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { @@ -970,39 +979,39 @@ object ChatController { return null } - suspend fun apiSetProfileAddress(on: Boolean): User? { + suspend fun apiSetProfileAddress(rh: Long?, on: Boolean): User? { val userId = try { currentUserId("apiSetProfileAddress") } catch (e: Exception) { return null } - return when (val r = sendCmd(CC.ApiSetProfileAddress(userId, on))) { + return when (val r = sendCmd(rh, CC.ApiSetProfileAddress(userId, on))) { is CR.UserProfileNoChange -> null is CR.UserProfileUpdated -> r.user else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") } } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { - val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) + suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? { + val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs)) if (r is CR.ContactPrefsUpdated) return r.toContact Log.e(TAG, "apiSetContactPrefs bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? { - val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias)) + suspend fun apiSetContactAlias(rh: Long?, contactId: Long, localAlias: String): Contact? { + val r = sendCmd(rh, CC.ApiSetContactAlias(contactId, localAlias)) if (r is CR.ContactAliasUpdated) return r.toContact Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiSetConnectionAlias(connId: Long, localAlias: String): PendingContactConnection? { - val r = sendCmd(CC.ApiSetConnectionAlias(connId, localAlias)) + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection Log.e(TAG, "apiSetConnectionAlias bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiCreateUserAddress(): String? { + suspend fun apiCreateUserAddress(rh: Long?): String? { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiCreateMyAddress(userId)) + val r = sendCmd(rh, CC.ApiCreateMyAddress(userId)) return when (r) { is CR.UserContactLinkCreated -> r.connReqContact else -> { @@ -1014,17 +1023,17 @@ object ChatController { } } - suspend fun apiDeleteUserAddress(): User? { + suspend fun apiDeleteUserAddress(rh: Long?): User? { val userId = try { currentUserId("apiDeleteUserAddress") } catch (e: Exception) { return null } - val r = sendCmd(CC.ApiDeleteMyAddress(userId)) + val r = sendCmd(rh, CC.ApiDeleteMyAddress(userId)) if (r is CR.UserContactLinkDeleted) return r.user Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return null } - private suspend fun apiGetUserAddress(): UserContactLinkRec? { + private suspend fun apiGetUserAddress(rh: Long?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null } - val r = sendCmd(CC.ApiShowMyAddress(userId)) + val r = sendCmd(rh, CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1035,9 +1044,9 @@ object ChatController { return null } - suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? { + suspend fun userAddressAutoAccept(rh: Long?, autoAccept: AutoAccept?): UserContactLinkRec? { val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null } - val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) + val r = sendCmd(rh, CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound @@ -1048,8 +1057,8 @@ object ChatController { return null } - suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? { - val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId)) + suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? { + val r = sendCmd(rh, CC.ApiAcceptContact(incognito, contactReqId)) return when { r is CR.AcceptingContactRequest -> r.contact r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1070,75 +1079,76 @@ object ChatController { } } - suspend fun apiRejectContactRequest(contactReqId: Long): Boolean { - val r = sendCmd(CC.ApiRejectContact(contactReqId)) + suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Boolean { + val r = sendCmd(rh, CC.ApiRejectContact(contactReqId)) if (r is CR.ContactRequestRejected) return true Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiSendCallInvitation(contact: Contact, callType: CallType): Boolean { - val r = sendCmd(CC.ApiSendCallInvitation(contact, callType)) + suspend fun apiSendCallInvitation(rh: Long?, contact: Contact, callType: CallType): Boolean { + val r = sendCmd(rh, CC.ApiSendCallInvitation(contact, callType)) return r is CR.CmdOk } - suspend fun apiRejectCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiRejectCall(contact)) + suspend fun apiRejectCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiRejectCall(contact)) return r is CR.CmdOk } - suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { + suspend fun apiSendCallOffer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) - val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) + val r = sendCmd(rh, CC.ApiSendCallOffer(contact, callOffer)) return r is CR.CmdOk } - suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallAnswer(rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) + val r = sendCmd(rh, CC.ApiSendCallAnswer(contact, answer)) return r is CR.CmdOk } - suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean { + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) - val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) + val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r is CR.CmdOk } - suspend fun apiEndCall(contact: Contact): Boolean { - val r = sendCmd(CC.ApiEndCall(contact)) + suspend fun apiEndCall(rh: Long?, contact: Contact): Boolean { + val r = sendCmd(rh, CC.ApiEndCall(contact)) return r is CR.CmdOk } - suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean { - val r = sendCmd(CC.ApiCallStatus(contact, status)) + suspend fun apiCallStatus(rh: Long?, contact: Contact, status: WebRTCCallStatus): Boolean { + val r = sendCmd(rh, CC.ApiCallStatus(contact, status)) return r is CR.CmdOk } - suspend fun apiGetNetworkStatuses(): List? { - val r = sendCmd(CC.ApiGetNetworkStatuses()) + suspend fun apiGetNetworkStatuses(rh: Long?): List? { + val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) if (r is CR.NetworkStatuses) return r.networkStatuses Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { - val r = sendCmd(CC.ApiChatRead(type, id, range)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, range: CC.ItemRange): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatUnread(type: ChatType, id: Long, unreadChat: Boolean): Boolean { - val r = sendCmd(CC.ApiChatUnread(type, id, unreadChat)) + suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { + val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatUnread bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline)) + suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { + // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected + val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline)) return when (r) { is CR.RcvFileAccepted -> r.chatItem is CR.RcvFileAcceptedSndCancelled -> { @@ -1166,16 +1176,16 @@ object ChatController { } } - suspend fun cancelFile(user: User, fileId: Long) { - val chatItem = apiCancelFile(fileId) + suspend fun cancelFile(rh: Long?, user: User, fileId: Long) { + val chatItem = apiCancelFile(rh, fileId) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rh, user, chatItem) cleanupFile(chatItem) } } - suspend fun apiCancelFile(fileId: Long): AChatItem? { - val r = sendCmd(CC.CancelFile(fileId)) + suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? { + val r = sendCmd(rh, CC.CancelFile(fileId)) return when (r) { is CR.SndFileCancelled -> r.chatItem is CR.RcvFileCancelled -> r.chatItem @@ -1186,16 +1196,16 @@ object ChatController { } } - suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) + val r = sendCmd(rh, CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { - val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { + val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) return when (r) { is CR.SentGroupInvitation -> r.member else -> { @@ -1207,14 +1217,14 @@ object ChatController { } } - suspend fun apiJoinGroup(groupId: Long) { - val r = sendCmd(CC.ApiJoinGroup(groupId)) + suspend fun apiJoinGroup(rh: Long?, groupId: Long) { + val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rh, r.groupInfo) is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(ChatType.Group, groupId)) { chatModel.removeChat("#$groupId") } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { chatModel.removeChat(rh, "#$groupId") } } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -1229,8 +1239,8 @@ object ChatController { } } - suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(CC.ApiRemoveMember(groupId, memberId))) { + suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = + when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { is CR.UserDeletedMember -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1240,8 +1250,8 @@ object ChatController { } } - suspend fun apiMemberRole(groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(CC.ApiMemberRole(groupId, memberId, memberRole))) { + suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = + when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { is CR.MemberRoleUser -> r.member else -> { if (!(networkErrorAlert(r))) { @@ -1251,22 +1261,22 @@ object ChatController { } } - suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { - val r = sendCmd(CC.ApiLeaveGroup(groupId)) + suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiLeaveGroup(groupId)) if (r is CR.LeftMemberUser) return r.groupInfo Log.e(TAG, "apiLeaveGroup bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiListMembers(groupId: Long): List { - val r = sendCmd(CC.ApiListMembers(groupId)) + suspend fun apiListMembers(rh: Long?, groupId: Long): List { + val r = sendCmd(rh, CC.ApiListMembers(groupId)) if (r is CR.GroupMembers) return r.group.members Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") return emptyList() } - suspend fun apiUpdateGroup(groupId: Long, groupProfile: GroupProfile): GroupInfo? { - return when (val r = sendCmd(CC.ApiUpdateGroupProfile(groupId, groupProfile))) { + suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { + return when (val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))) { is CR.GroupUpdated -> r.toGroup is CR.ChatCmdError -> { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.chatError") @@ -1283,8 +1293,8 @@ object ChatController { } } - suspend fun apiCreateGroupLink(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APICreateGroupLink(groupId, memberRole))) { + suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APICreateGroupLink(groupId, memberRole))) { is CR.GroupLinkCreated -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1295,8 +1305,8 @@ object ChatController { } } - suspend fun apiGroupLinkMemberRole(groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { - return when (val r = sendCmd(CC.APIGroupLinkMemberRole(groupId, memberRole))) { + suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): Pair? { + return when (val r = sendCmd(rh, CC.APIGroupLinkMemberRole(groupId, memberRole))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { if (!(networkErrorAlert(r))) { @@ -1307,8 +1317,8 @@ object ChatController { } } - suspend fun apiDeleteGroupLink(groupId: Long): Boolean { - return when (val r = sendCmd(CC.APIDeleteGroupLink(groupId))) { + suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean { + return when (val r = sendCmd(rh, CC.APIDeleteGroupLink(groupId))) { is CR.GroupLinkDeleted -> true else -> { if (!(networkErrorAlert(r))) { @@ -1319,8 +1329,8 @@ object ChatController { } } - suspend fun apiGetGroupLink(groupId: Long): Pair? { - return when (val r = sendCmd(CC.APIGetGroupLink(groupId))) { + suspend fun apiGetGroupLink(rh: Long?, groupId: Long): Pair? { + return when (val r = sendCmd(rh, CC.APIGetGroupLink(groupId))) { is CR.GroupLink -> r.connReqContact to r.memberRole else -> { Log.e(TAG, "apiGetGroupLink bad response: ${r.responseType} ${r.details}") @@ -1329,8 +1339,8 @@ object ChatController { } } - suspend fun apiCreateMemberContact(groupId: Long, groupMemberId: Long): Contact? { - return when (val r = sendCmd(CC.APICreateMemberContact(groupId, groupMemberId))) { + suspend fun apiCreateMemberContact(rh: Long?, groupId: Long, groupMemberId: Long): Contact? { + return when (val r = sendCmd(rh, CC.APICreateMemberContact(groupId, groupMemberId))) { is CR.NewMemberContact -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1341,8 +1351,8 @@ object ChatController { } } - suspend fun apiSendMemberContactInvitation(contactId: Long, mc: MsgContent): Contact? { - return when (val r = sendCmd(CC.APISendMemberContactInvitation(contactId, mc))) { + suspend fun apiSendMemberContactInvitation(rh: Long?, contactId: Long, mc: MsgContent): Contact? { + return when (val r = sendCmd(rh, CC.APISendMemberContactInvitation(contactId, mc))) { is CR.NewMemberContactSentInv -> r.contact else -> { if (!(networkErrorAlert(r))) { @@ -1353,23 +1363,105 @@ object ChatController { } } - suspend fun allowFeatureToContact(contact: Contact, feature: ChatFeature, param: Int? = null) { + suspend fun allowFeatureToContact(rh: Long?, contact: Contact, feature: ChatFeature, param: Int? = null) { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) - val toContact = apiSetContactPrefs(contact.contactId, prefs) + val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - chatModel.updateContact(toContact) + chatModel.updateContact(rh, toContact) } } - private suspend fun sendCommandOkResp(cmd: CC): Boolean { - val r = sendCmd(cmd) + suspend fun setLocalDeviceName(displayName: String): Boolean = sendCommandOkResp(null, CC.SetLocalDeviceName(displayName)) + + suspend fun listRemoteHosts(): List? { + val r = sendCmd(null, CC.ListRemoteHosts()) + if (r is CR.RemoteHostList) return r.remoteHosts + apiErrorAlert("listRemoteHosts", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun reloadRemoteHosts() { + val hosts = listRemoteHosts() ?: return + chatModel.remoteHosts.clear() + chatModel.remoteHosts.addAll(hosts) + } + + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = false): Pair? { + val r = sendCmd(null, 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(null, 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(null, 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(null, CC.DeleteRemoteHost(rhId)) + + suspend fun storeRemoteFile(rhId: Long, storeEncrypted: Boolean?, localPath: String): CryptoFile? { + val r = sendCmd(null, 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(null, CC.GetRemoteFile(rhId, file)) + + suspend fun connectRemoteCtrl(desktopAddress: String): Pair { + val r = sendCmd(null, CC.ConnectRemoteCtrl(desktopAddress)) + if (r is CR.RemoteCtrlConnecting) return SomeRemoteCtrl(r.remoteCtrl_, r.ctrlAppInfo, r.appVersion) to null + else if (r is CR.ChatCmdError) return null to r + else throw Exception("connectRemoteCtrl error: ${r.responseType} ${r.details}") + } + + suspend fun findKnownRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.FindKnownRemoteCtrl()) + + suspend fun confirmRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.ConfirmRemoteCtrl(rcId)) + + suspend fun verifyRemoteCtrlSession(sessionCode: String): RemoteCtrlInfo? { + val r = sendCmd(null, CC.VerifyRemoteCtrlSession(sessionCode)) + if (r is CR.RemoteCtrlConnected) return r.remoteCtrl + apiErrorAlert("verifyRemoteCtrlSession", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun listRemoteCtrls(): List? { + val r = sendCmd(null, CC.ListRemoteCtrls()) + if (r is CR.RemoteCtrlList) return r.remoteCtrls + apiErrorAlert("listRemoteCtrls", generalGetString(MR.strings.error_alert_title), r) + return null + } + + suspend fun stopRemoteCtrl(): Boolean = sendCommandOkResp(null, CC.StopRemoteCtrl()) + + suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId)) + + private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean { + val r = sendCmd(rh, cmd) val ok = r is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } suspend fun apiGetVersion(): CoreVersionInfo? { - val r = sendCmd(CC.ShowVersion()) + val r = sendCmd(null, CC.ShowVersion()) return if (r is CR.VersionInfo) { r.versionInfo } else { @@ -1402,38 +1494,41 @@ object ChatController { } } - fun apiErrorAlert(method: String, title: String, r: CR) { + private fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") AlertManager.shared.showAlertMsg(title, errMsg) } - suspend fun processReceivedMsg(r: CR) { + private suspend fun processReceivedMsg(apiResp: APIResponse) { lastMsgReceivedTimestamp = System.currentTimeMillis() - chatModel.addTerminalItem(TerminalItem.resp(r)) + val r = apiResp.resp + val rhId = apiResp.remoteHostId + fun active(user: UserLike): Boolean = activeUser(rhId, user) + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) when (r) { is CR.NewContactConnection -> { if (active(r.user)) { - chatModel.updateContactConnection(r.connection) + chatModel.updateContactConnection(rhId, r.connection) } } is CR.ContactConnectionDeleted -> { if (active(r.user)) { - chatModel.removeChat(r.connection.id) + chatModel.removeChat(rhId, r.connection.id) } } is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } if (r.contact.directOrUsed) { @@ -1443,11 +1538,11 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } } @@ -1455,31 +1550,31 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - if (chatModel.hasChat(contactRequest.id)) { - chatModel.updateChatInfo(cInfo) + if (chatModel.hasChat(rhId, contactRequest.id)) { + chatModel.updateChatInfo(rhId, cInfo) } else { - chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) } } ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(r.toContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - chatModel.updateChatInfo(cInfo) + chatModel.updateChatInfo(rhId, cInfo) } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.toMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.toMember) } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { + if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - chatModel.removeChat(r.mergedContact.id) + chatModel.removeChat(rhId, r.mergedContact.id) } } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) @@ -1487,7 +1582,7 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - chatModel.updateContact(sub.contact) + chatModel.updateContact(rhId, sub.contact) } val err = sub.contactError if (err == null) { @@ -1511,9 +1606,9 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { - chatModel.addChatItem(cInfo, cItem) + chatModel.addChatItem(rhId, cInfo, cItem) } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(r.user) + chatModel.increaseUnreadCounter(rhId, r.user) } val file = cItem.file val mc = cItem.content.msgContent @@ -1522,9 +1617,9 @@ 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)) { + if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId != rhId)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1536,7 +1631,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) @@ -1545,7 +1640,7 @@ object ChatController { is CR.ChatItemDeleted -> { if (!active(r.user)) { if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(r.user) + chatModel.decreaseUnreadCounter(rhId, r.user) } return } @@ -1564,76 +1659,76 @@ object ChatController { ) } if (r.toChatItem == null) { - chatModel.removeChatItem(cInfo, cItem) + chatModel.removeChatItem(rhId, cInfo, cItem) } else { - chatModel.upsertChatItem(cInfo, r.toChatItem.chatItem) + chatModel.upsertChatItem(rhId, cInfo, r.toChatItem.chatItem) } } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated + chatModel.updateGroup(rhId, r.groupInfo) // update so that repeat group invitations are not duplicated // TODO NtfManager.shared.notifyGroupInvitation } } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { chatModel.dismissConnReqView(conn.id) - chatModel.removeChat(conn.id) + chatModel.removeChat(rhId, conn.id) } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.dismissConnReqView(hostConn.id) - chatModel.removeChat(hostConn.id) + chatModel.removeChat(rhId, hostConn.id) } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.DeletedMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } is CR.LeftMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRole -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.MemberRoleUser -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.UserJoinedGroup -> if (active(r.user)) { - chatModel.updateGroup(r.groupInfo) + chatModel.updateGroup(rhId, r.groupInfo) } is CR.JoinedGroupMember -> if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - chatModel.upsertGroupMember(r.groupInfo, r.member) + chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) } if (r.memberContact != null) { chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) @@ -1641,48 +1736,48 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - chatModel.updateGroup(r.toGroup) + chatModel.updateGroup(rhId, r.toGroup) } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - chatModel.updateContact(r.contact) + chatModel.updateContact(rhId, 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 -> { - chatModel.callManager.reportNewIncomingCall(r.callInvitation) + chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { // TODO askConfirmation? @@ -1727,13 +1822,44 @@ object ChatController { } } is CR.ContactSwitch -> - chatModel.updateContactConnectionStats(r.contact, r.switchProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) is CR.GroupMemberSwitch -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.switchProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) is CR.ContactRatchetSync -> - chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) + chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) is CR.GroupMemberRatchetSync -> - chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + is CR.RemoteHostSessionCode -> { + chatModel.newRemoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) + } + is CR.RemoteHostConnected -> { + // TODO needs to update it instead in sessions + chatModel.currentRemoteHost.value = r.remoteHost + switchUIRemoteHost(r.remoteHost.remoteHostId) + } + is CR.RemoteHostStopped -> { + chatModel.newRemoteHostPairing.value = null + if (chatModel.currentRemoteHost.value != null) { + chatModel.currentRemoteHost.value = null + switchUIRemoteHost(null) + } + } + is CR.RemoteCtrlFound -> { + // TODO multicast + Log.d(TAG, "RemoteCtrlFound: ${r.remoteCtrl}") + } + is CR.RemoteCtrlSessionCode -> { + val state = UIRemoteCtrlSessionState.PendingConfirmation(remoteCtrl_ = r.remoteCtrl_, sessionCode = r.sessionCode) + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlConnected -> { + // TODO currently it is returned in response to command, so it is redundant + val state = UIRemoteCtrlSessionState.Connected(remoteCtrl = r.remoteCtrl, sessionCode = chatModel.remoteCtrlSession.value?.sessionCode ?: "") + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = state) + } + is CR.RemoteCtrlStopped -> { + switchToLocalSession() + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1757,7 +1883,25 @@ object ChatController { } } - private fun active(user: UserLike): Boolean = user.userId == chatModel.currentUser.value?.userId + fun switchToLocalSession() { + val m = chatModel + m.remoteCtrlSession.value = null + withBGApi { + val users = listUsers(null) + m.users.clear() + m.users.addAll(users) + getUserChatData(null) + val statuses = apiGetNetworkStatuses(null) + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + } + } + + private fun activeUser(rhId: Long?, user: UserLike): Boolean = + rhId == chatModel.remoteHostId && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value @@ -1768,27 +1912,27 @@ object ChatController { } } - suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { - val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto) + suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) { + val chatItem = apiReceiveFile(rhId, fileId, encrypted = encrypted, auto = auto) if (chatItem != null) { - chatItemSimpleUpdate(user, chatItem) + chatItemSimpleUpdate(rhId, user, chatItem) } } - suspend fun leaveGroup(groupId: Long) { - val groupInfo = apiLeaveGroup(groupId) + suspend fun leaveGroup(rh: Long?, groupId: Long) { + val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(rh, groupInfo) } } - private suspend fun chatItemSimpleUpdate(user: UserLike, aChatItem: AChatItem) { + private suspend fun chatItemSimpleUpdate(rh: 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(rh, user)) { notify() - } else if (chatModel.upsertChatItem(cInfo, cItem)) { + } else if (chatModel.upsertChatItem(rh, cInfo, cItem)) { notify() } } @@ -1814,6 +1958,26 @@ object ChatController { chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } + suspend fun switchUIRemoteHost(rhId: Long?) { + // TODO lock the switch so that two switches can't run concurrently? + chatModel.chatId.value = null + chatModel.currentRemoteHost.value = switchRemoteHost(rhId) + reloadRemoteHosts() + val user = apiGetActiveUser(rhId) + val users = listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + chatModel.currentUser.value = user + chatModel.userCreated.value = true + val statuses = apiGetNetworkStatuses(rhId) + if (statuses != null) { + chatModel.networkStatuses.clear() + val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() + chatModel.networkStatuses.putAll(ss) + } + getUserChatData(rhId) + } + fun getXFTPCfg(): XFTPFileConfig { return XFTPFileConfig(minFileSize = 0) } @@ -1917,6 +2081,7 @@ sealed class CC { class ApiStopChat: CC() class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() + class SetRemoteHostsFolder(val remoteHostsFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() @@ -1997,6 +2162,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 ListRemoteHosts(): 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 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 StopRemoteCtrl(): CC() + class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() + // misc class ShowVersion(): CC() val cmdString: String get() = when (this) { @@ -2026,6 +2208,7 @@ sealed class CC { is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" + is SetRemoteHostsFolder -> "/remote_hosts_folder $remoteHostsFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" @@ -2116,6 +2299,24 @@ sealed class CC { (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" + is SetLocalDeviceName -> "/set device name $displayName" + is ListRemoteHosts -> "/list remote hosts" + 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 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" is ShowVersion -> "/version" } @@ -2137,6 +2338,7 @@ sealed class CC { is ApiStopChat -> "apiStopChat" is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" + is SetRemoteHostsFolder -> "setRemoteHostsFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" @@ -2217,6 +2419,21 @@ sealed class CC { is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" + is SetLocalDeviceName -> "setLocalDeviceName" + is ListRemoteHosts -> "listRemoteHosts" + is StartRemoteHost -> "startRemoteHost" + is SwitchRemoteHost -> "switchRemoteHost" + is StopRemoteHost -> "stopRemoteHost" + is DeleteRemoteHost -> "deleteRemoteHost" + is StoreRemoteFile -> "storeRemoteFile" + is GetRemoteFile -> "getRemoteFile" + is ConnectRemoteCtrl -> "connectRemoteCtrl" + is FindKnownRemoteCtrl -> "FindKnownRemoteCtrl" + is ConfirmRemoteCtrl -> "confirmRemoteCtrl" + is VerifyRemoteCtrlSession -> "verifyRemoteCtrlSession" + is ListRemoteCtrls -> "listRemoteCtrls" + is StopRemoteCtrl -> "stopRemoteCtrl" + is DeleteRemoteCtrl -> "deleteRemoteCtrl" is ShowVersion -> "showVersion" } @@ -2314,6 +2531,7 @@ data class UserProtocolServers( @Serializable data class ServerCfg( + val remoteHostId: Long? = null, val server: String, val preset: Boolean, val tested: Boolean? = null, @@ -3279,6 +3497,46 @@ enum class GroupFeatureEnabled { } +@Serializable +data class RemoteCtrl ( + val remoteCtrlId: Long, + val displayName: String, + val fingerprint: String, + val accepted: Boolean? +) + +@Serializable +data class RemoteCtrlInfo ( + val remoteCtrlId: Long, + val ctrlDeviceName: String, + val sessionState: RemoteCtrlSessionState? +) { + val deviceViewName: String + get() = ctrlDeviceName.ifEmpty { remoteCtrlId.toString() } +} + +@Serializable +data class RemoteHostInfo( + val remoteHostId: Long, + val hostDeviceName: String, + val storePath: String, + 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 ignoreUnknownKeys = true @@ -3292,7 +3550,7 @@ val yaml = Yaml(configuration = YamlConfiguration( )) @Serializable -class APIResponse(val resp: CR, val corr: String? = null) { +class APIResponse(val resp: CR, val remoteHostId: Long?, val corr: String? = null) { companion object { fun decodeStr(str: String): APIResponse { return try { @@ -3302,48 +3560,35 @@ class APIResponse(val resp: CR, val corr: String? = null) { Log.d(TAG, e.localizedMessage ?: "") val data = json.parseToJsonElement(str).jsonObject val resp = data["resp"]!!.jsonObject - val type = resp["type"]?.jsonPrimitive?.content ?: "invalid" + val type = resp["type"]?.jsonPrimitive?.contentOrNull ?: "invalid" + val corr = data["corr"]?.toString() + val remoteHostId = data["remoteHostId"]?.jsonPrimitive?.longOrNull try { if (type == "apiChats") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chats: List = resp["chats"]!!.jsonArray.map { parseChatData(it) } - return APIResponse( - resp = CR.ApiChats(user, chats), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChats(user, chats), remoteHostId, corr) } else if (type == "apiChat") { val user: UserRef = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chat = parseChatData(resp["chat"]!!) - return APIResponse( - resp = CR.ApiChat(user, chat), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ApiChat(user, chat), remoteHostId, corr) } else if (type == "chatCmdError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } else if (type == "chatError") { val userObject = resp["user_"]?.jsonObject val user = runCatching { json.decodeFromJsonElement(userObject!!) }.getOrNull() - return APIResponse( - resp = CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), - corr = data["corr"]?.toString() - ) + return APIResponse(CR.ChatRespError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatError"]))), remoteHostId, corr) } } catch (e: Exception) { Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString()) } - APIResponse( - resp = CR.Response(type, json.encodeToString(data)), - corr = data["corr"]?.toString() - ) + APIResponse(CR.Response(type, json.encodeToString(data)), remoteHostId, corr) } catch(e: Exception) { - APIResponse(CR.Invalid(str)) + APIResponse(CR.Invalid(str), remoteHostId = null) } } } @@ -3357,7 +3602,7 @@ private fun parseChatData(chat: JsonElement): Chat { val chatItems: List = chat.jsonObject["chatItems"]!!.jsonArray.map { decodeObject(ChatItem.serializer(), it) ?: parseChatItem(it) } - return Chat(chatInfo, chatItems, chatStats) + return Chat(remoteHostId = null, chatInfo, chatItems, chatStats) } private fun parseChatItem(j: JsonElement): ChatItem { @@ -3446,7 +3691,6 @@ sealed class CR { @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: UserRef, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() - @Serializable @SerialName("contactsList") class ContactsList(val user: UserRef, val contacts: List): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @@ -3502,11 +3746,28 @@ sealed class CR { @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): 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("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("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR() + // remote events (mobile) + @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List): CR() + @Serializable @SerialName("remoteCtrlFound") class RemoteCtrlFound(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() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() + // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -3583,7 +3844,6 @@ sealed class CR { is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemReaction -> "chatItemReaction" is ChatItemDeleted -> "chatItemDeleted" - is ContactsList -> "contactsList" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -3635,6 +3895,20 @@ sealed class CR { is CallEnded -> "callEnded" is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" + 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 RemoteCtrlFound -> "remoteCtrlFound" + is RemoteCtrlConnecting -> "remoteCtrlConnecting" + is RemoteCtrlSessionCode -> "remoteCtrlSessionCode" + is RemoteCtrlConnected -> "remoteCtrlConnected" + is RemoteCtrlStopped -> "remoteCtrlStopped" is VersionInfo -> "versionInfo" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" @@ -3717,7 +3991,6 @@ sealed class CR { is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") - is ContactsList -> withUser(user, json.encodeToString(contacts)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) @@ -3769,6 +4042,31 @@ sealed class CR { is CallEnded -> withUser(user, "contact: ${contact.id}") is NewContactConnection -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) + // 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 RemoteFileStored -> "remote host ID: $remoteHostId\nremoteFileSource:\n" + json.encodeToString(remoteFileSource) + is RemoteCtrlList -> json.encodeToString(remoteCtrls) + is RemoteCtrlFound -> 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" + "chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" + "agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}" @@ -3829,28 +4127,29 @@ sealed class GroupLinkPlan { abstract class TerminalItem { abstract val id: Long + abstract val remoteHostId: Long? val date: Instant = Clock.System.now() abstract val label: String abstract val details: String - class Cmd(override val id: Long, val cmd: CC): TerminalItem() { + class Cmd(override val id: Long, override val remoteHostId: Long?, val cmd: CC): TerminalItem() { override val label get() = "> ${cmd.cmdString}" override val details get() = cmd.cmdString } - class Resp(override val id: Long, val resp: CR): TerminalItem() { + class Resp(override val id: Long, override val remoteHostId: Long?, val resp: CR): TerminalItem() { override val label get() = "< ${resp.responseType}" override val details get() = resp.details } companion object { val sampleData = listOf( - Cmd(0, CC.ShowActiveUser()), - Resp(1, CR.ActiveUser(User.sampleData)) + Cmd(0, null, CC.ShowActiveUser()), + Resp(1, null, CR.ActiveUser(User.sampleData)) ) - fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c) - fun resp(r: CR) = Resp(System.currentTimeMillis(), r) + fun cmd(rhId: Long?, c: CC) = Cmd(System.currentTimeMillis(), rhId, c) + fun resp(rhId: Long?, r: CR) = Resp(System.currentTimeMillis(), rhId, r) } } @@ -3948,6 +4247,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) { @@ -3955,12 +4274,16 @@ sealed class ChatError { is ChatErrorAgent -> "agent ${agentError.string}" is ChatErrorStore -> "store ${storeError.string}" is ChatErrorDatabase -> "database ${databaseError.string}" + is ChatErrorRemoteHost -> "remoteHost ${remoteHostError.string}" + is ChatErrorRemoteCtrl -> "remoteCtrl ${remoteCtrlError.string}" is ChatErrorInvalidJSON -> "invalid json ${json}" } @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() @Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError() + @Serializable @SerialName("errorRemoteHost") class ChatErrorRemoteHost(val remoteHostError: RemoteHostError): ChatError() + @Serializable @SerialName("errorRemoteCtrl") class ChatErrorRemoteCtrl(val remoteCtrlError: RemoteCtrlError): ChatError() @Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError() } @@ -4272,6 +4595,7 @@ sealed class AgentErrorType { is SMP -> "SMP ${smpErr.string}" // is NTF -> "NTF ${ntfErr.string}" is XFTP -> "XFTP ${xftpErr.string}" + is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" is INTERNAL -> "INTERNAL $internalErr" @@ -4282,6 +4606,7 @@ sealed class AgentErrorType { @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() // @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType() @Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType() + @Serializable @SerialName("XFTP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @@ -4452,6 +4777,38 @@ sealed class XFTPErrorType { @Serializable @SerialName("INTERNAL") object INTERNAL: XFTPErrorType() } +@Serializable +sealed class RCErrorType { + val string: String get() = when (this) { + is INTERNAL -> "INTERNAL $internalErr" + is IDENTITY -> "IDENTITY" + is NO_LOCAL_ADDRESS -> "NO_LOCAL_ADDRESS" + is TLS_START_FAILED -> "TLS_START_FAILED" + is EXCEPTION -> "EXCEPTION $EXCEPTION" + is CTRL_AUTH -> "CTRL_AUTH" + is CTRL_NOT_FOUND -> "CTRL_NOT_FOUND" + is CTRL_ERROR -> "CTRL_ERROR $ctrlErr" + is VERSION -> "VERSION" + is ENCRYPT -> "ENCRYPT" + is DECRYPT -> "DECRYPT" + is BLOCK_SIZE -> "BLOCK_SIZE" + is SYNTAX -> "SYNTAX $syntaxErr" + } + @Serializable @SerialName("internal") data class INTERNAL(val internalErr: String): RCErrorType() + @Serializable @SerialName("identity") object IDENTITY: RCErrorType() + @Serializable @SerialName("noLocalAddress") object NO_LOCAL_ADDRESS: RCErrorType() + @Serializable @SerialName("tlsStartFailed") object TLS_START_FAILED: RCErrorType() + @Serializable @SerialName("exception") data class EXCEPTION(val exception: String): RCErrorType() + @Serializable @SerialName("ctrlAuth") object CTRL_AUTH: RCErrorType() + @Serializable @SerialName("ctrlNotFound") object CTRL_NOT_FOUND: RCErrorType() + @Serializable @SerialName("ctrlError") data class CTRL_ERROR(val ctrlErr: String): RCErrorType() + @Serializable @SerialName("version") object VERSION: RCErrorType() + @Serializable @SerialName("encrypt") object ENCRYPT: RCErrorType() + @Serializable @SerialName("decrypt") object DECRYPT: RCErrorType() + @Serializable @SerialName("blockSize") object BLOCK_SIZE: RCErrorType() + @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() +} + @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -4462,6 +4819,46 @@ sealed class ArchiveError { @Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError() } +@Serializable +sealed class RemoteHostError { + val string: String get() = when (this) { + is Missing -> "missing" + is Inactive -> "inactive" + is Busy -> "busy" + is Timeout -> "timeout" + is BadState -> "badState" + is BadVersion -> "badVersion" + is Disconnected -> "disconnected" + } + @Serializable @SerialName("missing") object Missing: RemoteHostError() + @Serializable @SerialName("inactive") object Inactive: RemoteHostError() + @Serializable @SerialName("busy") object Busy: 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 +sealed class RemoteCtrlError { + val string: String get() = when (this) { + is Inactive -> "inactive" + is BadState -> "badState" + is Busy -> "busy" + is Timeout -> "timeout" + is Disconnected -> "disconnected" + is BadInvitation -> "badInvitation" + is BadVersion -> "badVersion" + } + @Serializable @SerialName("inactive") object Inactive: RemoteCtrlError() + @Serializable @SerialName("badState") object BadState: RemoteCtrlError() + @Serializable @SerialName("busy") object Busy: RemoteCtrlError() + @Serializable @SerialName("timeout") object Timeout: RemoteCtrlError() + @Serializable @SerialName("disconnected") class Disconnected(val remoteCtrlId: Long, val reason: String): RemoteCtrlError() + @Serializable @SerialName("badInvitation") object BadInvitation: RemoteCtrlError() + @Serializable @SerialName("badVersion") data class BadVersion(val appVersion: String): RemoteCtrlError() + //@Serializable @SerialName("protocolError") data class ProtocolError(val protocolError: RemoteProtocolError): RemoteCtrlError() +} enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index b10a30233..7d5b1b019 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -18,6 +18,8 @@ enum class AppPlatform { expect val appPlatform: AppPlatform +expect val deviceName: String + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else 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..3d3a91cb3 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 @@ -52,7 +53,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } else if (startChat) { // If we migrated successfully means previous re-encryption process on database level finished successfully too if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) - val user = chatController.apiGetActiveUser() + val user = chatController.apiGetActiveUser(null) if (user == null) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 71a9f204f..877356e43 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -24,6 +24,8 @@ expect val agentDatabaseFileName: String * */ expect val databaseExportDir: File +expect val remoteHostsDir: File + expect fun desktopOpenDatabaseDir() fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { @@ -59,14 +61,20 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } fun getAppFilePath(fileName: String): String { - return appFilesDir.absolutePath + File.separator + fileName + val rh = chatModel.currentRemoteHost.value + val s = File.separator + return if (rh == null) { + appFilesDir.absolutePath + s + fileName + } else { + remoteHostsDir.absolutePath + s + rh.storePath + s + "simplex_v1_files" + s + fileName + } } fun getLoadedFilePath(file: CIFile?): String? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) filePath else null + if (fileReady(file, filePath)) filePath else null } else { null } @@ -76,12 +84,17 @@ fun getLoadedFileSource(file: CIFile?): CryptoFile? { val f = file?.fileSource?.filePath return if (f != null && file.loaded) { val filePath = getAppFilePath(f) - if (File(filePath).exists()) file.fileSource else null + if (fileReady(file, filePath)) file.fileSource else null } else { null } } +private fun fileReady(file: CIFile, filePath: String) = + File(filePath).exists() && + !CIFile.cachedRemoteFileRequests.contains(file.fileSource) + && File(filePath).length() >= file.fileSize + /** * [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index a03df5add..06925e28a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -49,7 +49,8 @@ abstract class NtfManager { null } val apiId = chatId.replace("<@", "").toLongOrNull() ?: return - acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel) + // TODO include remote host in notification + acceptContactRequest(null, incognito, apiId, cInfo, isCurrentUser, ChatModel) cancelNotificationsForChat(chatId) } @@ -57,11 +58,12 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true - if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel) + if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(null, cInfo, chatModel) } } @@ -69,7 +71,8 @@ abstract class NtfManager { withBGApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { - chatModel.controller.changeActiveUser(userId, null) + // TODO include remote host ID in desktop notifications? + chatModel.controller.changeActiveUser(null, userId, null) } chatModel.chatId.value = null chatModel.clearOverlays.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index a471b5645..ec2082557 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -47,13 +47,14 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState) { val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> + val rhId = item.remoteHostId + val rhIdStr = if (rhId == null) "" else "$rhId " Text( - "${item.date.toString().subSequence(11, 19)} ${item.label}", + "$rhIdStr${item.date.toString().subSequence(11, 19)} ${item.label}", style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 504ecac89..fb15f0aba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -170,18 +170,19 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { + val rhId = chatModel.remoteHostId val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + rhId, Profile(displayName.trim(), "", null) ) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) close() } } @@ -190,7 +191,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { chatModel.controller.apiCreateActiveUser( - Profile(displayName.trim(), "", null) + null, Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index f601776f9..d0c9a6e4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -43,6 +43,7 @@ class CallManager(val chatModel: ChatModel) { private fun justAcceptIncomingCall(invitation: RcvCallInvitation) { with (chatModel) { activeCall.value = Call( + remoteHostId = invitation.remoteHostId, contact = invitation.contact, callState = CallState.InvitationAccepted, localMedia = invitation.callType.media, @@ -76,7 +77,7 @@ class CallManager(val chatModel: ChatModel) { Log.d(TAG, "CallManager.endCall: ending call...") callCommand.add(WCallCommand.End) showCallView.value = false - controller.apiEndCall(call.contact) + controller.apiEndCall(call.remoteHostId, call.contact) activeCall.value = null } } @@ -90,7 +91,7 @@ class CallManager(val chatModel: ChatModel) { ntfManager.cancelCallNotification() } withApi { - if (!controller.apiRejectCall(invitation.contact)) { + if (!controller.apiRejectCall(invitation.remoteHostId, invitation.contact)) { Log.e(TAG, "apiRejectCall error") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 4be49d4c0..64904ba7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -11,6 +11,7 @@ import java.util.* import kotlin.collections.ArrayList data class Call( + val remoteHostId: Long? = null, val contact: Contact, val callState: CallState, val localMedia: CallMediaType, @@ -95,7 +96,14 @@ sealed class WCallResponse { @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) @Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) { +@Serializable data class RcvCallInvitation( + val remoteHostId: Long? = null, + val user: User, + val contact: Contact, + val callType: CallType, + val sharedKey: String? = null, + val callTs: Instant +) { val callTypeText: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> if (sharedKey == null) MR.strings.video_call_no_encryption else MR.strings.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) MR.strings.audio_call_no_encryption else MR.strings.encrypted_audio_call diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index b7c5e66a6..5816c8952 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -61,6 +61,7 @@ fun ChatInfoView( val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { mutableStateOf(chatModel.contactNetworkStatus(contact)) } + val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } ChatInfoLayout( chat, @@ -81,25 +82,25 @@ fun ChatInfoView( connectionCode, developerTools, onLocalAliasChanged = { - setContactAlias(chat.chatInfo.apiId, it, chatModel) + setContactAlias(chat, it, chatModel) }, openPreferences = { ModalManager.end.showCustomModal { close -> val user = chatModel.currentUser.value if (user != null) { - ContactPreferencesView(chatModel, user, contact.contactId, close) + ContactPreferencesView(chatModel, user, chatRh, contact.contactId, close) } } }, - deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, + deleteContact = { deleteContactDialog(chat, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, switchContactAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val cStats = chatModel.controller.apiSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -108,20 +109,20 @@ fun ChatInfoView( abortSwitchContactAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val cStats = chatModel.controller.apiAbortSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }) }, syncContactConnection = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -129,10 +130,10 @@ fun ChatInfoView( syncContactConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = true) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } close.invoke() } @@ -146,9 +147,10 @@ fun ChatInfoView( connectionCode, ct.verified, verify = { code -> - chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r -> + chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r chatModel.updateContact( + chatRh, ct.copy( activeConn = ct.activeConn?.copy( connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null @@ -195,7 +197,8 @@ sealed class SendReceipts { } } -fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), @@ -206,7 +209,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = true) + deleteContact(chat, chatModel, close, notify = true) } }) { Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -215,7 +218,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close, notify = false) + deleteContact(chat, chatModel, close, notify = false) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -225,7 +228,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> SectionItemView({ AlertManager.shared.hideAlert() withApi { - deleteContact(chatInfo, chatModel, close) + deleteContact(chat, chatModel, close) } }) { Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -242,11 +245,13 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> ) } -fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + val chatInfo = chat.chatInfo withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + val chatRh = chat.remoteHostId + val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chatRh, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -257,16 +262,18 @@ fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? } } -fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun clearChatDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), text = generalGetString(MR.strings.clear_chat_warning), confirmText = generalGetString(MR.strings.clear_verb), onConfirm = { withApi { - val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId) + val chatRh = chat.remoteHostId + val updatedChatInfo = chatModel.controller.apiClearChat(chatRh, chatInfo.chatType, chatInfo.apiId) if (updatedChatInfo != null) { - chatModel.clearChat(updatedChatInfo) + chatModel.clearChat(chatRh, updatedChatInfo) ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } @@ -669,9 +676,10 @@ fun ShareAddressButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let { - chatModel.updateContact(it) +private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + chatModel.updateContact(chatRh, it) } } 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 7d83693f0..862212217 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 @@ -100,11 +100,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } val view = LocalMultiplatformView() - if (activeChat.value == null || user == null) { + val chat = activeChat.value + if (chat == null || user == null) { chatModel.chatId.value = null ModalManager.end.closeModals() } else { - val chat = activeChat.value!! + val chatRh = chat.remoteHostId // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -166,11 +167,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var preloadedCode: String? = null var preloadedLink: Pair? = null if (chat.chatInfo is ChatInfo.Direct) { - preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) - preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second } else if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) - preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) } ModalManager.end.showModalCloseable(true) { close -> val chat = remember { activeChat }.value @@ -178,20 +179,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)?.second + code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second preloadedCode = code } ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } else if (chat?.chatInfo is ChatInfo.Group) { var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chat.id) { - setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) + setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { link = it preloadedLink = it }, close) @@ -202,19 +203,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) } } } @@ -225,7 +226,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (c != null && firstId != null) { withApi { Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") - apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + apiLoadPrevMessages(c, chatModel, firstId, searchText.value) Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } @@ -241,6 +242,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val toChatItem: ChatItem? if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { val r = chatModel.controller.apiDeleteMemberChatItem( + chatRh, groupId = groupInfo.groupId, groupMemberId = groupMember.groupMemberId, itemId = itemId @@ -249,6 +251,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.second } else { val r = chatModel.controller.apiDeleteChatItem( + chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = itemId, @@ -258,9 +261,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: toChatItem = r?.toChatItem?.chatItem } if (toChatItem == null && deletedChatItem != null) { - chatModel.removeChatItem(cInfo, deletedChatItem) + chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) } else if (toChatItem != null) { - chatModel.upsertChatItem(cInfo, toChatItem) + chatModel.upsertChatItem(chatRh, cInfo, toChatItem) } } }, @@ -271,27 +274,27 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val deletedItems: ArrayList = arrayListOf() for (itemId in itemIds) { val di = chatModel.controller.apiDeleteChatItem( - chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal )?.deletedChatItem?.chatItem if (di != null) { deletedItems.add(di) } } for (di in deletedItems) { - chatModel.removeChatItem(chatInfo, di) + chatModel.removeChatItem(chatRh, chatInfo, di) } } } }, receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } + withApi { chatModel.controller.receiveFile(chatRh, user, fileId, encrypted) } }, cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(user, fileId) } + withApi { chatModel.controller.cancelFile(chatRh, user, fileId) } }, joinGroup = { groupId, onComplete -> withApi { - chatModel.controller.apiJoinGroup(groupId) + chatModel.controller.apiJoinGroup(chatRh, groupId) onComplete.invoke() } }, @@ -299,7 +302,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) + chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true chatModel.callCommand.add(WCallCommand.Capabilities(media)) } @@ -320,48 +323,48 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, acceptFeature = { contact, feature, param -> withApi { - chatModel.controller.allowFeatureToContact(contact, feature, param) + chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param) } }, openDirectChat = { contactId -> withApi { - openDirectChat(contactId, chatModel) + openDirectChat(chatRh, contactId, chatModel) } }, updateContactStats = { contact -> withApi { - val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) if (r != null) { val contactStats = r.first if (contactStats != null) - chatModel.updateContactConnectionStats(contact, contactStats) + chatModel.updateContactConnectionStats(chatRh, contact, contactStats) } } }, updateMemberStats = { groupInfo, member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) if (r != null) { val memStats = r.second if (memStats != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) } } } }, syncContactConnection = { contact -> withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - chatModel.updateContactConnectionStats(contact, cStats) + chatModel.updateContactConnectionStats(chatRh, contact, cStats) } } }, syncMemberConnection = { groupInfo, member -> withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) } } }, @@ -374,6 +377,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setReaction = { cInfo, cItem, add, reaction -> withApi { val updatedCI = chatModel.controller.apiChatItemReaction( + rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, itemId = cItem.id, @@ -387,10 +391,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, showItemDetails = { cInfo, cItem -> withApi { - val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chat.chatInfo.groupInfo, chatModel) + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) } ModalManager.end.closeModals() ModalManager.end.showModal(endButtons = { ShareButton { @@ -404,28 +408,29 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: addMembers = { groupInfo -> hideKeyboard(view) withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(chatRh, groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) } } }, openGroupLink = { groupInfo -> hideKeyboard(view) withApi { - val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) } } }, markRead = { range, unreadCountAfter -> - chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) + chatModel.markChatItemsRead(chat, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) withBGApi { chatModel.controller.apiChatRead( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, range @@ -437,7 +442,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (searchText.value == value) return@ChatLayout val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout withApi { - apiFindMessages(c.chatInfo, chatModel, value) + apiFindMessages(c, chatModel, value) searchText.value = value } }, @@ -498,6 +503,7 @@ fun ChatLayout( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { + // TODO: file is not saved anywhere?! val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() chatModel.filesToDelete.add(tmpFile) @@ -1252,14 +1258,16 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha val chat = activeChat.value if (chat?.chatStats?.unreadChat != true) return withApi { + val chatRh = chat.remoteHostId val success = chatModel.controller.apiChatUnread( + chatRh, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success && chat.id == activeChat.value?.id) { activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)) - chatModel.replaceChat(chat.id, activeChat.value!!) + chatModel.replaceChat(chatRh, chat.id, activeChat.value!!) } } } @@ -1299,7 +1307,7 @@ private fun providerForGallery( scrollTo: (Int) -> Unit ): ImageGalleryProvider { fun canShowMedia(item: ChatItem): Boolean = - (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(item.file) != null) + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { var processedInternalIndex = -skipInternalIndex.sign @@ -1326,7 +1334,7 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val res = getLoadedImage(item.file) + val res = runBlocking { getLoadedImage(item.file) } val filePath = getLoadedFilePath(item.file) if (res != null && filePath != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -1334,7 +1342,7 @@ private fun providerForGallery( } else null } is MsgContent.MCVideo -> { - val filePath = getLoadedFilePath(item.file) + val filePath = if (chatModel.connectedToRemote() && item.file?.loaded == true) getAppFilePath(item.file.fileName) else getLoadedFilePath(item.file) if (filePath != null) { val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 959ded42b..b8076b147 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -15,6 +15,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.Indigo import chat.simplex.common.ui.theme.isSystemInDarkTheme @@ -349,8 +351,10 @@ fun ComposeView( } } - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { + val cInfo = chat.chatInfo val aChatItem = chatModel.controller.apiSendMessage( + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, file = file, @@ -360,7 +364,7 @@ fun ComposeView( ttl = ttl ) if (aChatItem != null) { - chatModel.addChatItem(cInfo, aChatItem.chatItem) + chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) return aChatItem.chatItem } if (file != null) removeFile(file.filePath) @@ -407,23 +411,25 @@ fun ComposeView( suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() - val contact = chatModel.controller.apiSendMemberContactInvitation(chat.chatInfo.apiId, mc) + val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(chat.remoteHostId, contact) } } - suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { + suspend fun updateMessage(ei: ChatItem, chat: Chat, live: Boolean): ChatItem? { + val cInfo = chat.chatInfo val oldMsgContent = ei.content.msgContent if (oldMsgContent != null) { val updatedItem = chatModel.controller.apiUpdateChatItem( + rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, mc = updateMsgContent(oldMsgContent), live = live ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) return updatedItem?.chatItem } return null @@ -441,21 +447,29 @@ fun ComposeView( sent = null } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem - sent = updateMessage(ei, cInfo, live) + sent = updateMessage(ei, chat, live) } else if (liveMessage != null && liveMessage.sent) { - sent = updateMessage(liveMessage.chatItem, cInfo, live) + sent = updateMessage(liveMessage.chatItem, chat, live) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() + val remoteHost = chatModel.currentRemoteHost.value when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.MediaPreview -> { preview.content.forEachIndexed { index, it -> + val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) - is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false) + is UploadContent.SimpleImage -> + if (remoteHost == null) saveImage(it.uri, encrypted = encrypted) + else desktopSaveImageInTmp(it.uri) + is UploadContent.AnimatedImage -> + if (remoteHost == null) saveAnimImage(it.uri, encrypted = encrypted) + else CryptoFile.desktopPlain(it.uri) + is UploadContent.Video -> + if (remoteHost == null) saveFileFromUri(it.uri, encrypted = false) + else CryptoFile.desktopPlain(it.uri) } if (file != null) { files.add(file) @@ -470,22 +484,32 @@ fun ComposeView( is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) - files.add(withContext(Dispatchers.IO) { - if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { - val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) - tmpFile.delete() - CryptoFile(actualFile.name, args) - } else { - Files.move(tmpFile.toPath(), actualFile.toPath()) - CryptoFile.plain(actualFile.name) - } - }) - deleteUnusedFiles() + if (remoteHost == null) { + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) + files.add(withContext(Dispatchers.IO) { + if (chatController.appPrefs.privacyEncryptLocalFiles.get()) { + val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath) + tmpFile.delete() + CryptoFile(actualFile.name, args) + } else { + Files.move(tmpFile.toPath(), actualFile.toPath()) + CryptoFile.plain(actualFile.name) + } + }) + deleteUnusedFiles() + } else { + files.add(CryptoFile.plain(tmpFile.absolutePath)) + // It will be deleted on JVM shutdown or next start (if the app crashes unexpectedly) + filesToDelete.remove(tmpFile) + } msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + val file = if (remoteHost == null) { + saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()) + } else { + CryptoFile.desktopPlain(preview.uri) + } if (file != null) { files.add((file)) msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) @@ -499,7 +523,15 @@ fun ComposeView( sent = null msgs.forEachIndexed { index, content -> if (index > 0) delay(100) - sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + var file = files.getOrNull(index) + if (remoteHost != null && file != null) { + file = controller.storeRemoteFile( + rhId = remoteHost.remoteHostId, + storeEncrypted = if (content is MsgContent.MCVideo) false else null, + localPath = file.filePath + ) + } + sent = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) @@ -509,7 +541,7 @@ fun ComposeView( cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } clearState(live) @@ -544,7 +576,7 @@ fun ComposeView( fun allowVoiceToContact() { val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return withApi { - chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) + chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Voice) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 465603d40..c12982ada 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR fun ContactPreferencesView( m: ChatModel, user: User, + rhId: Long?, contactId: Long, close: () -> Unit, ) { @@ -36,9 +37,9 @@ fun ContactPreferencesView( fun savePrefs(afterSave: () -> Unit = {}) { withApi { val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) - val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs) + val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - m.updateContact(toContact) + m.updateContact(rhId, toContact) currentFeaturesAllowed = featuresAllowed } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 91fb4a6e8..8ce39eea3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -11,10 +11,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @Composable -expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) - -@Composable -fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { +fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 90ab1b45f..ff23d40b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.res.MR @Composable -fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { +fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) { val selectedContacts = remember { mutableStateListOf() } val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) } var allowModifyMembers by remember { mutableStateOf(true) } @@ -49,16 +49,16 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch searchText, openPreferences = { ModalManager.end.showCustomModal { close -> - GroupPreferencesView(chatModel, groupInfo.id, close) + GroupPreferencesView(chatModel, rhId, groupInfo.id, close) } }, inviteMembers = { allowModifyMembers = false withApi { for (contactId in selectedContacts) { - val member = chatModel.controller.apiAddMember(groupInfo.groupId, contactId, selectedRole.value) + val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - chatModel.upsertGroupMember(groupInfo, member) + chatModel.upsertGroupMember(rhId, groupInfo, member) } else { break } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 8c9619703..49d76d8ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -41,9 +41,10 @@ import kotlinx.coroutines.launch const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { +fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + // TODO derivedStateOf? + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } val currentUser = chatModel.currentUser.value val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { @@ -68,25 +69,25 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR groupLink, addMembers = { withApi { - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } }, showMemberInfo = { member -> withApi { - val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + val memCode = chatModel.controller.apiGetGroupMemberCode(rhId, groupInfo.apiId, member.groupMemberId) member to memCode?.second } else { member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() } @@ -95,31 +96,33 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR } }, editGroupProfile = { - ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } + ModalManager.end.showCustomModal { close -> GroupProfileView(rhId, groupInfo, chatModel, close) } }, addOrEditWelcomeMessage = { - ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) } + ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( chatModel, + rhId, chat.id, close ) } }, - deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, - leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, + deleteGroup = { deleteGroupDialog(chat, groupInfo, chatModel, close) }, + clearChat = { clearChatDialog(chat, chatModel, close) }, + leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } } ) } } -fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo val alertTextKey = if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning @@ -129,9 +132,9 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(chat.remoteHostId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -145,14 +148,14 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM ) } -fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.leave_group_question), text = generalGetString(MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { withApi { - chatModel.controller.leaveGroup(groupInfo.groupId) + chatModel.controller.leaveGroup(rhId, groupInfo.groupId) close?.invoke() } }, @@ -160,16 +163,16 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } -private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { +private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member_question), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) if (updatedMember != null) { - chatModel.upsertGroupMember(groupInfo, updatedMember) + chatModel.upsertGroupMember(rhId, groupInfo, updatedMember) } } }, @@ -260,7 +263,7 @@ fun GroupChatInfoLayout( Divider() val showMenu = remember { mutableStateOf(false) } SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { - DropDownMenuForMember(member, groupInfo, showMenu) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) MemberRow(member, onClick = { showMemberInfo(member) }) } } @@ -413,22 +416,22 @@ private fun MemberVerifiedShield() { } @Composable -private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { +private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { DefaultDropdownMenu(showMenu) { if (member.canBeRemoved(groupInfo)) { ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberAlert(groupInfo, member) + removeMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } if (member.memberSettings.showMessages) { ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { - blockMemberAlert(groupInfo, member) + blockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } else { ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { - unblockMemberAlert(groupInfo, member) + unblockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 809c7c2fd..02ce90243 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -25,6 +25,7 @@ import chat.simplex.res.MR @Composable fun GroupLinkView( chatModel: ChatModel, + rhId: Long?, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, @@ -38,7 +39,7 @@ fun GroupLinkView( fun createLink() { creatingLink = true withApi { - val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId) + val link = chatModel.controller.apiCreateGroupLink(rhId, groupInfo.groupId) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -62,7 +63,7 @@ fun GroupLinkView( val role = groupLinkMemberRole.value if (role != null) { withBGApi { - val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role) + val link = chatModel.controller.apiGroupLinkMemberRole(rhId, groupInfo.groupId, role) if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second @@ -78,7 +79,7 @@ fun GroupLinkView( confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) + val r = chatModel.controller.apiDeleteGroupLink(rhId, groupInfo.groupId) if (r) { groupLink = null onGroupLinkUpdated?.invoke(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 9f52f61de..00b236c7d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -40,6 +40,7 @@ import kotlinx.datetime.Clock @Composable fun GroupMemberInfoView( + rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats?, @@ -49,7 +50,7 @@ fun GroupMemberInfoView( closeAll: () -> Unit, // Close all open windows up to ChatView ) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } @@ -66,7 +67,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withApi { - val c = chatModel.controller.apiGetChat(ChatType.Direct, it) + val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) if (c != null) { if (chatModel.getContactChat(it) == null) { chatModel.addChat(c) @@ -82,9 +83,9 @@ fun GroupMemberInfoView( createMemberContact = { withApi { progressIndicator = true - val memberContact = chatModel.controller.apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { - val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) + val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) openLoadedChat(memberChat, chatModel) closeAll() @@ -94,11 +95,11 @@ fun GroupMemberInfoView( } }, connectViaAddress = { connReqUri -> - connectViaMemberAddressAlert(connReqUri) + connectViaMemberAddressAlert(rhId, connReqUri) }, - blockMember = { blockMemberAlert(groupInfo, member) }, - unblockMember = { unblockMemberAlert(groupInfo, member) }, - removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, + blockMember = { blockMemberAlert(rhId, groupInfo, member) }, + unblockMember = { unblockMemberAlert(rhId, groupInfo, member) }, + removeMember = { removeMemberDialog(rhId, groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value @@ -108,8 +109,8 @@ fun GroupMemberInfoView( }) { withApi { kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it) - chatModel.upsertGroupMember(groupInfo, mem) + val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) + chatModel.upsertGroupMember(rhId, groupInfo, mem) }.onFailure { newRole.value = prevValue } @@ -119,10 +120,10 @@ fun GroupMemberInfoView( switchMemberAddress = { showSwitchAddressAlert(switchAddress = { withApi { - val r = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -131,10 +132,10 @@ fun GroupMemberInfoView( abortSwitchMemberAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - val r = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -142,10 +143,10 @@ fun GroupMemberInfoView( }, syncMemberConnection = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -153,10 +154,10 @@ fun GroupMemberInfoView( syncMemberConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = { withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = true) + val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) close.invoke() } } @@ -170,9 +171,10 @@ fun GroupMemberInfoView( connectionCode, mem.verified, verify = { code -> - chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r -> + chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r chatModel.upsertGroupMember( + rhId, groupInfo, mem.copy( activeConn = mem.activeConn?.copy( @@ -196,16 +198,16 @@ fun GroupMemberInfoView( } } -fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.button_remove_member), text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withApi { - val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) if (removedMember != null) { - chatModel.upsertGroupMember(groupInfo, removedMember) + chatModel.upsertGroupMember(rhId, groupInfo, removedMember) } close?.invoke() } @@ -500,11 +502,11 @@ private fun updateMemberRoleDialog( ) } -fun connectViaMemberAddressAlert(connReqUri: String) { +fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) + planAndConnect(chatModel, rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -514,39 +516,39 @@ fun connectViaMemberAddressAlert(connReqUri: String) { } } -fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun blockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_member_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, false) + toggleShowMemberMessages(rhId, gInfo, mem, false) }, destructive = true, ) } -fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { +fun unblockMemberAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.unblock_member_question), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_member_confirmation), onConfirm = { - toggleShowMemberMessages(gInfo, mem, true) + toggleShowMemberMessages(rhId, gInfo, mem, true) }, ) } -fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { +fun toggleShowMemberMessages(rhId: Long?, gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) - updateMemberSettings(gInfo, member, updatedMemberSettings) + updateMemberSettings(rhId, gInfo, member, updatedMemberSettings) } -fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { +fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { withBGApi { - val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + ChatModel.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 4571a38c1..3cdfaad2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,8 +21,12 @@ import chat.simplex.common.model.* import chat.simplex.res.MR @Composable -fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { - val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } } +fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch?.remoteHostId != rhId) null else g + }} val gInfo = groupInfo.value ?: return var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) } var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) } @@ -30,9 +34,9 @@ fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val g = m.controller.apiUpdateGroup(gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - m.updateGroup(g) + m.updateGroup(rhId, g) currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 5376cb092..f92fd88dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -30,15 +30,15 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { +fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { GroupProfileLayout( close = close, groupProfile = groupInfo.groupProfile, saveProfile = { p -> withApi { - val gInfo = chatModel.controller.apiUpdateGroup(groupInfo.groupId, p) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - chatModel.updateGroup(gInfo) + chatModel.updateGroup(rhId, gInfo) close.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 3be54376d..577c19648 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -30,7 +30,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.delay @Composable -fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { +fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () -> Unit) { var gInfo by remember { mutableStateOf(groupInfo) } val welcomeText = remember { mutableStateOf(gInfo.groupProfile.description ?: "") } @@ -41,10 +41,10 @@ fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) { welcome = null } val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome) - val res = m.controller.apiUpdateGroup(gInfo.groupId, groupProfileUpdated) + val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - m.updateGroup(res) + m.updateGroup(rhId, res) welcomeText.value = welcome ?: "" } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 57dcd16cb..0d439f123 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -94,13 +94,19 @@ fun CIFileView( ) } is CIFileStatus.RcvComplete -> { - val filePath = getLoadedFilePath(file) - if (filePath != null) { - withApi { - saveFileLauncher.launch(file.fileName) + withBGApi { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath != null) { + withApi { + saveFileLauncher.launch(file.fileName) + } + } else { + showToast(generalGetString(MR.strings.file_not_found)) } - } else { - showToast(generalGetString(MR.strings.file_not_found)) } } else -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 23d1f1d0c..8b0b2debc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -22,6 +22,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.runBlocking import java.io.File import java.net.URI @@ -134,7 +137,7 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Triple? { + suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { val (imageBitmap: ImageBitmap, data: ByteArray) = res @@ -148,9 +151,23 @@ fun CIImageView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val res = remember(file) { imageAndFilePath(file) } - if (res != null) { - val (imageBitmap, data, _) = res + val res: MutableState?> = remember { + mutableStateOf( + if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } + ) + } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } + val loaded = res.value + if (loaded != null) { + val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 996dc819f..04ec30735 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.flow.* import java.io.File import java.net.URI @@ -37,10 +38,21 @@ fun CIVideoView( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), contentAlignment = Alignment.TopEnd ) { - val filePath = remember(file) { getLoadedFilePath(file) } val preview = remember(image) { base64ToBitmap(image) } - if (file != null && filePath != null) { - val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } + if (chatModel.connectedToRemote()) { + LaunchedEffect(file) { + withBGApi { + if (file != null && file.loaded && getLoadedFilePath(file) == null) { + file.loadRemoteFile(false) + filePath.value = getLoadedFilePath(file) + } + } + } + } + val f = filePath.value + if (file != null && f != null) { + val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } val view = LocalMultiplatformView() VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { hideKeyboard(view) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 941bc315b..0c8487458 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.* // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -44,16 +44,25 @@ fun CIVoiceView( ) { if (file != null) { val f = file.fileSource?.filePath - val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) } + val fileSource = remember(f, file.fileStatus, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFileSource(file)) } var brokenAudio by rememberSaveable(f) { mutableStateOf(false) } val audioPlaying = rememberSaveable(f) { mutableStateOf(false) } val progress = rememberSaveable(f) { mutableStateOf(0) } val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) } - val play = { - if (fileSource != null) { - AudioPlayer.play(fileSource, audioPlaying, progress, duration, true) - brokenAudio = !audioPlaying.value + val play: () -> Unit = { + val playIfExists = { + if (fileSource.value != null) { + AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true) + brokenAudio = !audioPlaying.value + } } + if (chatModel.connectedToRemote() && fileSource.value == null) { + withBGApi { + file.loadRemoteFile(true) + fileSource.value = getLoadedFileSource(file) + playIfExists() + } + } else playIfExists() } val pause = { AudioPlayer.pause(audioPlaying, progress) @@ -68,7 +77,7 @@ fun CIVoiceView( } } VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { - AudioPlayer.seekTo(it, progress, fileSource?.filePath) + AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) 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..17e2fe044 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 @@ -194,19 +194,34 @@ fun ChatItemView( }) } val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || !cachedRemoteReqs.contains(cItem.file.fileSource) + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && !cachedRemoteReqs.contains(cItem.file?.fileSource)))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { @@ -590,7 +605,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/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 13380a664..7e81faf3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -62,7 +62,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, showMenu, stopped, @@ -72,7 +72,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Group -> ChatListNavLinkLayout( chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, - click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, @@ -81,8 +81,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.ContactRequest -> ChatListNavLinkLayout( chatLinkPreview = { ContactRequestView(chat.chatInfo) }, - click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, + click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, + dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -94,10 +94,10 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) + ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, - dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, showMenu, stopped, selectedChat @@ -119,38 +119,38 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } -fun directChatAction(contact: Contact, chatModel: ChatModel) { +fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { - contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close = null, openChat = true) - else -> withBGApi { openChat(ChatInfo.Direct(contact), chatModel) } + contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) + else -> withBGApi { openChat(rhId, ChatInfo.Direct(contact), chatModel) } } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() - else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } + else -> withBGApi { openChat(rhId, ChatInfo.Group(groupInfo), chatModel) } } } -suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) +suspend fun openDirectChat(rhId: Long?, contactId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Direct, contactId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { - val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(rhId, ChatType.Group, groupId) if (chat != null) { openLoadedChat(chat, chatModel) } } -suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) + val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) if (chat != null) { openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") @@ -164,22 +164,24 @@ fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatId.value = chat.chatInfo.id } -suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) { +suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { + val chatInfo = ch.chatInfo val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) { - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return +suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { + val chatInfo = ch.chatInfo + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return if (chatModel.chatId.value != chat.id) return chatModel.chatItems.clear() chatModel.chatItems.addAll(0, chat.chatItems) } -suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { - val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { + val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) val currentMembers = chatModel.groupMembers val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } @@ -230,7 +232,7 @@ fun GroupMenuItems( } GroupMemberStatus.MemAccepted -> { if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -246,7 +248,7 @@ fun GroupMenuItems( ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) ClearChatAction(chat, chatModel, showMenu) if (groupInfo.membership.memberCurrent) { - LeaveGroupAction(groupInfo, chatModel, showMenu) + LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -310,7 +312,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState Unit = { withApi { inProgress.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(chat.remoteHostId, groupInfo.groupId) inProgress.value = false } } @@ -370,12 +372,12 @@ fun JoinGroupAction( } @Composable -fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { +fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.leave_group_button), painterResource(MR.images.ic_logout), onClick = { - leaveGroupDialog(groupInfo, chatModel) + leaveGroupDialog(rhId, groupInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -383,13 +385,13 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab } @Composable -fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { +fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.accept_contact_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -398,7 +400,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo painterResource(MR.images.ic_theater_comedy), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -406,7 +408,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo stringResource(MR.strings.reject_contact_button), painterResource(MR.images.ic_close), onClick = { - rejectContactRequest(chatInfo, chatModel) + rejectContactRequest(rhId, chatInfo, chatModel) showMenu.value = false }, color = Color.Red @@ -414,7 +416,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo } @Composable -fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { +fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(MR.strings.set_contact_name), painterResource(MR.images.ic_edit), @@ -422,7 +424,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ModalManager.center.closeModals() ModalManager.end.closeModals() ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) + ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) } showMenu.value = false }, @@ -431,7 +433,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { - deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) { + deleteContactConnectionAlert(rhId, chatInfo.contactConnection, chatModel) { if (chatModel.chatId.value == null) { ModalManager.center.closeModals() ModalManager.end.closeModals() @@ -471,8 +473,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { withApi { if (chat.chatStats.unreadCount > 0) { val minUnreadItemId = chat.chatStats.minUnreadItemId - chatModel.markChatItemsRead(chat.chatInfo) + chatModel.markChatItemsRead(chat) chatModel.controller.apiChatRead( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, CC.ItemRange(minUnreadItemId, chat.chatItems.last().id) @@ -481,12 +484,13 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { } if (chat.chatStats.unreadChat) { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, false ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) } } } @@ -498,17 +502,18 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { withApi { val success = chatModel.controller.apiChatUnread( + chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, true ) if (success) { - chatModel.replaceChat(chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) } } } -fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), @@ -516,19 +521,19 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel Column { SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel) }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - rejectContactRequest(contactRequest, chatModel) + rejectContactRequest(rhId, contactRequest, chatModel) }) { Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } @@ -537,24 +542,24 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel ) } -fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { +fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { withApi { - val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId) + val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { - val chat = Chat(ChatInfo.Direct(contact), listOf()) - chatModel.replaceChat(contactRequest.id, chat) + val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) + chatModel.replaceChat(rhId, contactRequest.id, chat) } } } -fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withApi { - chatModel.controller.apiRejectContactRequest(contactRequest.apiId) - chatModel.removeChat(contactRequest.id) + chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) + chatModel.removeChat(rhId, contactRequest.id) } } -fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { +fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), text = generalGetString( @@ -565,8 +570,8 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel onConfirm = { withApi { AlertManager.shared.hideAlert() - if (chatModel.controller.apiDeleteChat(ChatType.ContactConnection, connection.apiId)) { - chatModel.removeChat(connection.id) + if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { + chatModel.removeChat(rhId, connection.id) onSuccess() } } @@ -575,16 +580,17 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel ) } -fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { +// TODO why is it not used +fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.alert_title_contact_connection_pending), text = generalGetString(MR.strings.alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry), confirmText = generalGetString(MR.strings.button_delete_contact), onConfirm = { withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chatInfo.id) + chatModel.removeChat(rhId, chatInfo.id) if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -599,6 +605,7 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { fun askCurrentOrIncognitoProfileConnectContactViaAddress( chatModel: ChatModel, + rhId: Long?, contact: Contact, close: (() -> Unit)?, openChat: Boolean @@ -611,9 +618,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = false) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -623,9 +630,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( AlertManager.shared.hideAlert() withApi { close?.invoke() - val ok = connectContactViaAddress(chatModel, contact.contactId, incognito = true) + val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) if (ok && openChat) { - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } }) { @@ -641,10 +648,10 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( ) } -suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, incognito: Boolean): Boolean { - val contact = chatModel.controller.apiConnectContactViaAddress(incognito, contactId) +suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { + val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - chatModel.updateContact(contact) + chatModel.updateContact(rhId, contact) AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) @@ -654,7 +661,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, contactId: Long, inco return false } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), @@ -662,12 +669,12 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, onConfirm = { withApi { inProgress?.value = true - chatModel.controller.apiJoinGroup(groupInfo.groupId) + chatModel.controller.apiJoinGroup(rhId, groupInfo.groupId) inProgress?.value = false } }, dismissText = generalGetString(MR.strings.delete_verb), - onDismiss = { deleteGroup(groupInfo, chatModel) } + onDismiss = { deleteGroup(rhId, groupInfo, chatModel) } ) } @@ -679,11 +686,11 @@ fun cantInviteIncognitoAlert() { ) } -fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { +fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withApi { - val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) + val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - chatModel.removeChat(groupInfo.id) + chatModel.removeChat(rhId, groupInfo.id) if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -723,15 +730,15 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo withApi { val res = when (newChatInfo) { is ChatInfo.Direct -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, contact.chatSettings) } is ChatInfo.Group -> with(newChatInfo) { - chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings) + chatModel.controller.apiSetSettings(chat.remoteHostId, chatType, apiId, groupInfo.chatSettings) } else -> false } if (res && newChatInfo != null) { - chatModel.updateChatInfo(newChatInfo) + chatModel.updateChatInfo(chat.remoteHostId, newChatInfo) if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } 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..1644d286f 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.* @@ -62,7 +53,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val url = chatModel.appOpenUrl.value if (url != null) { chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(url, chatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, url, chatModel) } } if (appPlatform.isDesktop) { @@ -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) @@ -126,14 +117,16 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + // TODO disable this button and sheet for the duration of the switch + NewChatSheet(chatModel, chatModel.remoteHostId, 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 +217,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 +247,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) + } } } } @@ -314,13 +318,13 @@ private fun ProgressIndicator() { @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) -fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { +fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { withApi { - planAndConnect(chatModel, uri, incognito = null, close = null) + planAndConnect(chatModel, rhId, uri, incognito = null, close = null) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index e423a591d..ad8f93990 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -20,13 +20,13 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { directChatAction(chat.chatInfo.contact, chatModel) }, + click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, stopped ) is ChatInfo.Group -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, + click = { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) }, stopped ) is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {} 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..20e856df6 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,16 @@ 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.ConnectDesktopView +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,10 +39,11 @@ import kotlin.math.roundToInt fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - switchingUsers: MutableState, + switchingUsersAndHosts: MutableState, showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, + useFromDesktopClicked: () -> Unit = {}, settingsClicked: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -53,6 +60,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 { @@ -71,7 +84,7 @@ fun UserPicker( .filter { it } .collect { try { - val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + val updatedUsers = chatModel.controller.listUsers(chatModel.remoteHostId).sortedByDescending { it.user.activeUser } var same = users.size == updatedUsers.size if (same) { for (i in 0 until minOf(users.size, updatedUsers.size)) { @@ -90,8 +103,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.remoteHostId, 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 +160,72 @@ 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)) - if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.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 (appPlatform.isAndroid) { + UseFromDesktopPickerItem { + ModalManager.start.showCustomModal { close -> + ConnectDesktopView(close) + } + userPickerState.value = AnimatedViewState.GONE + } + Divider(Modifier.requiredHeight(1.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 +237,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 +290,107 @@ 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 UseFromDesktopPickerItem(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) + Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { 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 +400,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/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 0cca35474..1f4be2966 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -65,6 +65,8 @@ fun DatabaseView( Box( Modifier.fillMaxSize(), ) { + val user = m.currentUser.value + val rhId = user?.remoteHostId DatabaseLayout( progressIndicator.value, remember { m.chatRunning }.value != false, @@ -80,7 +82,7 @@ fun DatabaseView( chatLastStart, appFilesCountAndSize, chatItemTTL, - m.currentUser.value, + user, m.users, startChat = { startChat(m, chatLastStart, m.chatDbChanged) }, stopChatAlert = { stopChatAlert(m) }, @@ -91,9 +93,9 @@ fun DatabaseView( val oldValue = chatItemTTL.value chatItemTTL.value = it if (it < oldValue) { - setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setChatItemTTLAlert(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } else if (it != oldValue) { - setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize) + setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, showSettingsModal @@ -265,7 +267,7 @@ fun DatabaseLayout( } private fun setChatItemTTLAlert( - m: ChatModel, selectedChatItemTTL: MutableState, + m: ChatModel, rhId: Long?, selectedChatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, ) { @@ -273,7 +275,7 @@ private fun setChatItemTTLAlert( title = generalGetString(MR.strings.enable_automatic_deletion_question), text = generalGetString(MR.strings.enable_automatic_deletion_message), confirmText = generalGetString(MR.strings.delete_messages), - onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, + onConfirm = { setCiTTL(m, rhId, selectedChatItemTTL, progressIndicator, appFilesCountAndSize) }, onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }, destructive = true, ) @@ -592,6 +594,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { private fun setCiTTL( m: ChatModel, + rhId: Long?, chatItemTTL: MutableState, progressIndicator: MutableState, appFilesCountAndSize: MutableState>, @@ -600,7 +603,7 @@ private fun setCiTTL( progressIndicator.value = true withApi { try { - m.controller.setChatItemTTL(chatItemTTL.value) + m.controller.setChatItemTTL(rhId, chatItemTTL.value) // Update model on success m.chatItemTTL.value = chatItemTTL.value afterSetCiTTL(m, progressIndicator, appFilesCountAndSize) @@ -623,7 +626,8 @@ private fun afterSetCiTTL( withApi { try { updatingChatsMutex.withLock { - val chats = m.controller.apiGetChats() + // this is using current remote host on purpose - if it changes during update, it will load correct chats + val chats = m.controller.apiGetChats(m.remoteHostId) m.updateChats(chats) } } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 35d5b8b3e..fa9c89384 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -188,6 +188,25 @@ class AlertManager { ) } } + + fun showAlertMsgWithProgress( + title: String, + text: String? = null + ) { + showAlert { + AlertDialog( + onDismissRequest = this::hideAlert, + title = alertTitle(title), + text = alertText(text), + buttons = { + Box(Modifier.fillMaxWidth().height(72.dp).padding(bottom = DEFAULT_PADDING * 2), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) + } + } + ) + } + } + fun showAlertMsg( title: StringResource, text: StringResource? = null, 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/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 5e64de2c5..dbadec32f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -52,6 +52,14 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { } } +@Composable +fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { + val density = LocalDensity.current + return remember(id) { + escapedHtmlToAnnotatedString(id.localized().format(args), density) + } +} + // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 @@ -67,7 +75,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB expect fun getAppFileUri(fileName: String): URI // https://developer.android.com/training/data-storage/shared/documents-files#bitmap -expect fun getLoadedImage(file: CIFile?): Pair? +expect suspend fun getLoadedImage(file: CIFile?): Pair? expect fun getFileName(uri: URI): String? @@ -106,7 +114,7 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray()) @@ -124,6 +132,24 @@ fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? { } } +fun desktopSaveImageInTmp(uri: URI): CryptoFile? { + val image = getBitmapFromUri(uri) ?: return null + return try { + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val destFileName = generateNewFileName("IMG", ext, tmpDir) + val destFile = File(tmpDir, destFileName) + val output = FileOutputStream(destFile) + dataResized.writeTo(output) + output.flush() + output.close() + CryptoFile.plain(destFile.absolutePath) + } catch (e: Exception) { + Log.e(TAG, "Util.kt desktopSaveImageInTmp error: ${e.stackTraceToString()}") + null + } +} + fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { return try { val filename = getFileName(uri)?.lowercase() @@ -134,7 +160,7 @@ fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val destFileName = generateNewFileName("IMG", ext) + val destFileName = generateNewFileName("IMG", ext, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readBytes() ?: return null) @@ -156,7 +182,7 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean, withAlertOnException: Boolean val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave) + val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -193,21 +219,21 @@ fun createTmpFileAndDelete(onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String): String { +fun generateNewFileName(prefix: String, ext: String, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext") + return uniqueCombine("${prefix}_$timestamp.$ext", dir) } -fun uniqueCombine(fileName: String): String { +fun uniqueCombine(fileName: String, dir: File): String { val orig = File(fileName) val name = orig.nameWithoutExtension val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f + return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) } @@ -347,7 +373,7 @@ inline fun serializableSaver(): Saver = Saver( fun UriHandler.openVerifiedSimplexUri(uri: String) { val URI = try { URI.create(uri) } catch (e: Exception) { null } if (URI != null) { - connectIfOpenedViaUri(URI, ChatModel) + connectIfOpenedViaUri(chatModel.remoteHostId, URI, ChatModel) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 8b5c2a833..c64c3dd29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -63,7 +63,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (!displayName.isNullOrEmpty()) { profile = Profile(displayName = displayName, fullName = "") } - val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true) + val createdUser = m.controller.apiCreateActiveUser(null, profile, pastTimestamp = true) m.currentUser.value = createdUser m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) if (createdUser != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index ef3633d1f..360667fcf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -25,11 +25,13 @@ import chat.simplex.res.MR @Composable fun AddContactView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String, contactConnection: MutableState ) { val clipboard = LocalClipboardManager.current AddContactLayout( + rhId = rhId, chatModel = chatModel, incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, @@ -52,6 +54,7 @@ fun AddContactView( @Composable fun AddContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, @@ -63,9 +66,9 @@ fun AddContactLayout( withApi { val contactConnVal = contactConnection.value if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let { + chatModel.controller.apiSetConnectionIncognito(rhId, contactConnVal.pccConnId, incognito.value)?.let { contactConnection.value = it - chatModel.updateContactConnection(it) + chatModel.updateContactConnection(rhId, it) } } } @@ -172,6 +175,7 @@ fun sharedProfileInfo( fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + rhId = null, chatModel = ChatModel, incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 9b2cedefa..d60ee7531 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -32,25 +32,25 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { AddGroupLayout( createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) + chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id - setGroupMembers(groupInfo, chatModel) + setGroupMembers(rhId, groupInfo, chatModel) close.invoke() if (!groupInfo.incognito) { ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt index a2bc2c4df..e41c8701e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -8,4 +8,4 @@ enum class ConnectViaLinkTab { } @Composable -expect fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) +expect fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index d04a85d90..50419cdaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -30,6 +30,7 @@ import chat.simplex.res.MR @Composable fun ContactConnectionInfoView( chatModel: ChatModel, + rhId: Long?, connReqInvitation: String?, contactConnection: PendingContactConnection, focusAlias: Boolean, @@ -55,8 +56,8 @@ fun ContactConnectionInfoView( connReq = connReqInvitation, contactConnection = contactConnection, focusAlias = focusAlias, - deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) }, - onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) }, + deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, + onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { @@ -165,9 +166,9 @@ fun DeleteButton(onClick: () -> Unit) { ) } -private fun setContactAlias(contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { - chatModel.controller.apiSetConnectionAlias(contactConnection.pccConnId, localAlias)?.let { - chatModel.updateContactConnection(it) +private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withApi { + chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { + chatModel.updateContactConnection(rhId, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index 94538655b..f4252f53b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -20,7 +20,7 @@ enum class CreateLinkTab { } @Composable -fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { +fun CreateLinkView(m: ChatModel, rhId: Long?, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } @@ -32,7 +32,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { && contactConnection.value == null && !creatingConnReq.value ) { - createInvitation(m, creatingConnReq, connReqInvitation, contactConnection) + createInvitation(m, rhId, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -65,10 +65,10 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(m, connReqInvitation.value ?: "", contactConnection) + AddContactView(m, rhId,connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { - UserAddressView(m, viaCreateLinkView = true, close = {}) + UserAddressView(m, rhId, viaCreateLinkView = true, close = {}) } } } @@ -100,13 +100,14 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { private fun createInvitation( m: ChatModel, + rhId: Long?, creatingConnReq: MutableState, connReqInvitation: MutableState, contactConnection: MutableState ) { creatingConnReq.value = true withApi { - val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get()) + val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) if (r != null) { connReqInvitation.value = r.first contactConnection.value = r.second diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 8ec54e344..382bc72e4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, rhId: Long?, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { + // TODO close new chat if remote host changes in model if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -41,17 +42,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow ConnectViaLinkView(chatModel, close) } + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, rhId, close) } }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, close) } + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, rhId, close) } }, closeNewChatSheet, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index b142b8e16..d40fa9762 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -24,11 +24,12 @@ import chat.simplex.res.MR import java.net.URI @Composable -fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { +fun PasteToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { @@ -41,6 +42,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @Composable fun PasteToConnectLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, @@ -52,7 +54,7 @@ fun PasteToConnectLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -124,6 +126,7 @@ fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 7629256fc..523b7e532 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -26,7 +26,7 @@ import chat.simplex.res.MR import java.net.URI @Composable -expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) +expect fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) enum class ConnectionLinkType { INVITATION, CONTACT, GROUP @@ -34,21 +34,22 @@ enum class ConnectionLinkType { suspend fun planAndConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, close: (() -> Unit)? ) { - val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString()) if (connectionPlan != null) { when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -62,12 +63,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), connectDestructive = true @@ -78,7 +79,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -93,7 +94,7 @@ suspend fun planAndConnect( is InvitationLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -104,10 +105,10 @@ suspend fun planAndConnect( ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), connectDestructive = false @@ -121,12 +122,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), connectDestructive = true @@ -140,12 +141,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), connectDestructive = true @@ -155,7 +156,7 @@ suspend fun planAndConnect( is ContactAddressPlan.ConnectingProhibit -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) @@ -164,7 +165,7 @@ suspend fun planAndConnect( is ContactAddressPlan.Known -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, close, contact) + openKnownContact(chatModel, rhId, close, contact) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) @@ -175,9 +176,9 @@ suspend fun planAndConnect( val contact = connectionPlan.contactAddressPlan.contact if (incognito != null) { close?.invoke() - connectContactViaAddress(chatModel, contact.contactId, incognito) + connectContactViaAddress(chatModel, rhId, contact.contactId, incognito) } else { - askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, contact, close, openChat = false) + askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } } } @@ -189,11 +190,11 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } } ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), connectDestructive = false @@ -203,7 +204,7 @@ suspend fun planAndConnect( is GroupLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + ownGroupLinkConfirmConnect(chatModel, rhId, uri, incognito, connectionPlan, groupInfo, close) } GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") @@ -212,12 +213,12 @@ suspend fun planAndConnect( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, destructive = true, ) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan, close, + chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), connectDestructive = true @@ -242,7 +243,7 @@ suspend fun planAndConnect( is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) @@ -253,10 +254,10 @@ suspend fun planAndConnect( } else { Log.d(TAG, "planAndConnect, plan error") if (incognito != null) { - connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close) } else { askCurrentOrIncognitoProfileAlert( - chatModel, uri, connectionPlan = null, close, + chatModel, rhId, uri, connectionPlan = null, close, title = generalGetString(MR.strings.connect_plan_connect_via_link), connectDestructive = false ) @@ -266,12 +267,13 @@ suspend fun planAndConnect( suspend fun connectViaUri( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)? ): Boolean { - val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { close?.invoke() @@ -298,6 +300,7 @@ fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType fun askCurrentOrIncognitoProfileAlert( chatModel: ChatModel, + rhId: Long?, uri: URI, connectionPlan: ConnectionPlan?, close: (() -> Unit)?, @@ -314,7 +317,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -322,7 +325,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -337,18 +340,19 @@ fun askCurrentOrIncognitoProfileAlert( ) } -fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { +fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) { withApi { val c = chatModel.getContactChat(contact.contactId) if (c != null) { close?.invoke() - openDirectChat(contact.contactId, chatModel) + openDirectChat(rhId, contact.contactId, chatModel) } } } fun ownGroupLinkConfirmConnect( chatModel: ChatModel, + rhId: Long?, uri: URI, incognito: Boolean?, connectionPlan: ConnectionPlan?, @@ -363,7 +367,7 @@ fun ownGroupLinkConfirmConnect( // Open group SectionItemView({ AlertManager.shared.hideAlert() - openKnownGroup(chatModel, close, groupInfo) + openKnownGroup(chatModel, rhId, close, groupInfo) }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -372,7 +376,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }) { Text( @@ -385,7 +389,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -394,7 +398,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.shared.hideAlert() withApi { - connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -411,12 +415,12 @@ fun ownGroupLinkConfirmConnect( ) } -fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { +fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { withApi { val g = chatModel.getGroupChat(groupInfo.groupId) if (g != null) { close?.invoke() - openGroupChat(groupInfo.groupId, chatModel) + openGroupChat(rhId, groupInfo.groupId, chatModel) } } } @@ -424,6 +428,7 @@ fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupI @Composable fun ConnectContactLayout( chatModel: ChatModel, + rhId: Long?, incognitoPref: SharedPreference, close: () -> Unit ) { @@ -435,7 +440,7 @@ fun ConnectContactLayout( try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, uri, incognito = incognito.value, close) + planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( @@ -487,6 +492,7 @@ fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModel = ChatModel, + rhId = null, incognitoPref = SharedPreference({ false }, {}), close = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 013222338..49e62fb06 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -23,14 +23,14 @@ import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable -fun CreateSimpleXAddress(m: ChatModel) { +fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) { var progressIndicator by remember { mutableStateOf(false) } val userAddress = remember { m.userAddress } val clipboard = LocalClipboardManager.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - prepareChatBeforeAddressCreation() + prepareChatBeforeAddressCreation(rhId) } CreateSimpleXAddressLayout( @@ -45,11 +45,11 @@ fun CreateSimpleXAddress(m: ChatModel) { createAddress = { withApi { progressIndicator = true - val connReqContact = m.controller.apiCreateUserAddress() + val connReqContact = m.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { m.userAddress.value = UserContactLinkRec(connReqContact) try { - val u = m.controller.apiSetProfileAddress(true) + val u = m.controller.apiSetProfileAddress(rhId, true) if (u != null) { m.updateUser(u) } @@ -176,18 +176,18 @@ private fun ProgressIndicator() { } } -private fun prepareChatBeforeAddressCreation() { +private fun prepareChatBeforeAddressCreation(rhId: Long?) { if (chatModel.users.isNotEmpty()) return withApi { - val user = chatModel.controller.apiGetActiveUser() ?: return@withApi + val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { chatModel.controller.startChat(user) } else { - val users = chatModel.controller.listUsers() + val users = chatModel.controller.listUsers(rhId) chatModel.users.clear() chatModel.users.addAll(users) - chatModel.controller.getUserChatData() + chatModel.controller.getUserChatData(rhId) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9bc5ae846..a4ebd23de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -80,7 +80,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { onDispose { if (m.chatRunning.value != true) { withBGApi { - val user = chatController.apiGetActiveUser() + val user = chatController.apiGetActiveUser(null) if (user != null) { m.controller.startChat(user) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt new file mode 100644 index 000000000..d631836dd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -0,0 +1,472 @@ +package chat.simplex.common.views.remote + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionSpacer +import SectionView +import TextIconSpaced +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.switchToLocalSession +import chat.simplex.common.model.ChatModel.connectedToRemote +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ConnectDesktopView(close: () -> Unit) { + val deviceName = remember { controller.appPrefs.deviceNameForRemoteAccess.state } + val closeWithAlert = { + if (!connectedToRemote()) { + close() + } else { + showDisconnectDesktopAlert(close) + } + } + ModalView(close = closeWithAlert) { + ConnectDesktopLayout( + deviceName = deviceName.value!!, + ) + } + val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } + DisposableEffect(Unit) { + withBGApi { + if (!ntfModeService) platform.androidServiceStart() + } + onDispose { + if (!ntfModeService) platform.androidServiceSafeStop() + } + } +} + +@Composable +private fun ConnectDesktopLayout(deviceName: String) { + val sessionAddress = remember { mutableStateOf("") } + val remoteCtrls = remember { mutableStateListOf() } + val session = remember { chatModel.remoteCtrlSession }.value + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + if (session != null) { + when (session.sessionState) { + is UIRemoteCtrlSessionState.Starting -> ConnectingDesktop(session, null) + is UIRemoteCtrlSessionState.Connecting -> ConnectingDesktop(session, session.sessionState.remoteCtrl_) + is UIRemoteCtrlSessionState.PendingConfirmation -> { + if (controller.appPrefs.confirmRemoteSessions.get() || session.sessionState.remoteCtrl_ == null) { + VerifySession(session, session.sessionState.remoteCtrl_, session.sessionCode!!, remoteCtrls) + } else { + ConnectingDesktop(session, session.sessionState.remoteCtrl_) + LaunchedEffect(Unit) { + verifyDesktopSessionCode(remoteCtrls, session.sessionCode!!) + } + } + } + + is UIRemoteCtrlSessionState.Connected -> ActiveSession(session, session.sessionState.remoteCtrl) + } + } else { + ConnectDesktop(deviceName, remoteCtrls, sessionAddress) + } + SectionBottomSpacer() + } + DisposableEffect(Unit) { + setDeviceName(deviceName) + updateRemoteCtrls(remoteCtrls) + onDispose { + if (chatModel.remoteCtrlSession.value != null) { + disconnectDesktop() + } + } + } +} + +@Composable +private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { + AppBarTitle(stringResource(MR.strings.connect_to_desktop)) + SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + DevicesView(deviceName, remoteCtrls) { + if (it != "") { + setDeviceName(it) + controller.appPrefs.deviceNameForRemoteAccess.set(it) + } + } + } + SectionDividerSpaced() + ScanDesktopAddressView(sessionAddress) + if (controller.appPrefs.developerTools.get()) { + SectionSpacer() + DesktopAddressView(sessionAddress) + } +} + +@Composable +private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) + SectionView(stringResource(MR.strings.connecting_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { + AppBarTitle(stringResource(MR.strings.verify_connection)) + SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + CtrlDeviceNameText(session, rc) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + SectionSpacer() + + SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SessionCodeText(sessCode) + } + + SectionSpacer() + + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.confirm_verb)) + } + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun CtrlDeviceNameText(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) { + val newDesktop = annotatedStringResource(MR.strings.new_desktop) + val text = remember(rc) { + var t = AnnotatedString(rc?.deviceViewName ?: session.ctrlAppInfo.deviceName) + if (rc == null) { + t = t + AnnotatedString(" ") + newDesktop + } + t + } + Text(text) +} + +@Composable +private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { + val thisDeviceVersion = annotatedStringResource(MR.strings.this_device_version, session.appVersion) + val text = remember(session) { + val v = AnnotatedString(session.ctrlAppInfo.appVersionRange.maxVersion) + var t = AnnotatedString("v$v") + if (v.text != session.appVersion) { + t = t + AnnotatedString(" ") + thisDeviceVersion + } + t + } + Text(text) +} + +@Composable +private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo) { + AppBarTitle(stringResource(MR.strings.connected_to_desktop)) + SectionView(stringResource(MR.strings.connected_desktop).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text(rc.deviceViewName) + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + CtrlDeviceVersionText(session) + } + + if (session.sessionCode != null) { + SectionSpacer() + SectionView(stringResource(MR.strings.session_code).uppercase()) { + SessionCodeText(session.sessionCode!!) + } + } + + SectionSpacer() + + SectionView { + DisconnectButton(::disconnectDesktop) + } +} + +@Composable +private fun SessionCodeText(code: String) { + SelectionContainer { + Text( + code.substring(0, 23), + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + } +} + +@Composable +private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList, updateDeviceName: (String) -> Unit) { + DeviceNameField(deviceName) { updateDeviceName(it) } + if (remoteCtrls.isNotEmpty()) { + SectionItemView({ ModalManager.start.showModal { LinkedDesktopsView(remoteCtrls) } }) { + Text(generalGetString(MR.strings.linked_desktops)) + } + } +} + +@Composable +private fun ScanDesktopAddressView(sessionAddress: MutableState) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + sessionAddress.value = text + processDesktopQRCode(sessionAddress, text) + } + } + } +} + +@Composable +private fun DesktopAddressView(sessionAddress: MutableState) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + if (sessionAddress.value.isEmpty()) { + SettingsActionItem( + painterResource(MR.images.ic_content_paste), + stringResource(MR.strings.paste_desktop_address), + disabled = !clipboard.hasText(), + click = { + sessionAddress.value = clipboard.getText()?.text ?: "" + }, + ) + } else { + Row(Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth()) { + val state = remember { + mutableStateOf(TextFieldValue(sessionAddress.value)) + } + DefaultBasicTextField( + Modifier.fillMaxWidth(), + state, + color = MaterialTheme.colors.secondary, + ) { + state.value = it + } + KeyChangeEffect(state.value.text) { + if (state.value.text.isNotEmpty()) { + sessionAddress.value = state.value.text + } + } + } + } + SettingsActionItem( + painterResource(MR.images.ic_wifi), + stringResource(MR.strings.connect_to_desktop), + disabled = sessionAddress.value.isEmpty(), + click = { + connectDesktopAddress(sessionAddress, sessionAddress.value) + }, + ) + } +} + +@Composable +private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.linked_desktops)) + SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + remoteCtrls.forEach { rc -> + val showMenu = rememberSaveable { mutableStateOf(false) } + SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { + RemoteCtrl(rc) + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), color = Color.Red) { + unlinkDesktop(remoteCtrls, rc) + showMenu.value = false + } + } + } + + } + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + PreferenceToggle(stringResource(MR.strings.verify_connections), remember { controller.appPrefs.confirmRemoteSessions.state }.value) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + PreferenceToggle(stringResource(MR.strings.discover_on_network), remember { controller.appPrefs.connectRemoteViaMulticast.state }.value && false) { + controller.appPrefs.confirmRemoteSessions.set(it) + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun RemoteCtrl(rc: RemoteCtrlInfo) { + Text(rc.deviceViewName) +} + +private fun setDeviceName(name: String) { + withBGApi { + controller.setLocalDeviceName(name) + } +} + +private fun updateRemoteCtrls(remoteCtrls: SnapshotStateList) { + withBGApi { + val res = controller.listRemoteCtrls() + if (res != null) { + remoteCtrls.clear() + remoteCtrls.addAll(res) + } + } +} + +private fun processDesktopQRCode(sessionAddress: MutableState, resp: String) { + connectDesktopAddress(sessionAddress, resp) +} + +private fun connectDesktopAddress(sessionAddress: MutableState, addr: String) { + withBGApi { + val res = controller.connectRemoteCtrl(desktopAddress = addr) + if (res.first != null) { + val (rc_, ctrlAppInfo, v) = res.first!! + sessionAddress.value = "" + chatModel.remoteCtrlSession.value = RemoteCtrlSession( + ctrlAppInfo = ctrlAppInfo, + appVersion = v, + sessionState = UIRemoteCtrlSessionState.Connecting(remoteCtrl_ = rc_) + ) + } else { + val e = res.second ?: return@withBGApi + when { + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadInvitation -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorChat && e.chatError.errorType is ChatErrorType.CommandError -> showBadInvitationErrorAlert() + e.chatError is ChatError.ChatErrorRemoteCtrl && e.chatError.remoteCtrlError is RemoteCtrlError.BadVersion -> showBadVersionAlert(v = e.chatError.remoteCtrlError.appVersion) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.VERSION -> showBadVersionAlert(v = null) + e.chatError is ChatError.ChatErrorAgent && e.chatError.agentError is AgentErrorType.RCP && e.chatError.agentError.rcpErr is RCErrorType.CTRL_AUTH -> showDesktopDisconnectedErrorAlert() + else -> { + val errMsg = "${e.responseType}: ${e.details}" + Log.e(TAG, "bad response: $errMsg") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), errMsg) + } + } + } + } +} + +private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList, sessCode: String) { + withBGApi { + val rc = controller.verifyRemoteCtrlSession(sessCode) + if (rc != null) { + chatModel.remoteCtrlSession.value = chatModel.remoteCtrlSession.value?.copy(sessionState = UIRemoteCtrlSessionState.Connected(remoteCtrl = rc, sessionCode = sessCode)) + } + updateRemoteCtrls(remoteCtrls) + } +} + +@Composable +private fun DisconnectButton(onClick: () -> Unit) { + SectionItemView(onClick) { + Icon(painterResource(MR.images.ic_close), generalGetString(MR.strings.disconnect_remote_host), tint = MaterialTheme.colors.secondary) + TextIconSpaced(false) + Text(generalGetString(MR.strings.disconnect_remote_host)) + } +} + +private fun disconnectDesktop(close: (() -> Unit)? = null) { + withBGApi { + controller.stopRemoteCtrl() + switchToLocalSession() + close?.invoke() + } +} + +private fun unlinkDesktop(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + withBGApi { + controller.deleteRemoteCtrl(rc.remoteCtrlId) + remoteCtrls.removeAll { it.remoteCtrlId == rc.remoteCtrlId } + } +} + +private fun showUnlinkDesktopAlert(remoteCtrls: SnapshotStateList, rc: RemoteCtrlInfo) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unlink_desktop_question), + confirmText = generalGetString(MR.strings.unlink_desktop), + destructive = true, + onConfirm = { + unlinkDesktop(remoteCtrls, rc) + } + ) +} + +private fun showDisconnectDesktopAlert(close: (() -> Unit)?) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.disconnect_desktop_question), + text = generalGetString(MR.strings.only_one_device_can_work_at_the_same_time), + confirmText = generalGetString(MR.strings.disconnect_remote_host), + destructive = true, + onConfirm = { disconnectDesktop(close) } + ) +} + +private fun showBadInvitationErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.bad_desktop_address), + ) +} + +private fun showBadVersionAlert(v: String?) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_incompatible_version), + text = generalGetString(MR.strings.desktop_app_version_is_incompatible).format(v ?: "") + ) +} + +private fun showDesktopDisconnectedErrorAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.desktop_connection_terminated), + ) +} 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..0d90e5945 --- /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 +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/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 215899d0f..f3496c850 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -34,7 +34,7 @@ fun HiddenProfileView( saveProfilePassword = { hidePassword -> withBGApi { try { - val u = m.controller.apiHideUser(user.userId, hidePassword) + val u = m.controller.apiHideUser(user, hidePassword) m.updateUser(u) close() } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index f2fee926a..9ee1b3938 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -168,9 +168,9 @@ fun NetworkAndServersView( ) { AppBarTitle(stringResource(MR.strings.network_and_servers)) SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.SMP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 202602b28..e94c53f64 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -25,11 +25,11 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { fun savePrefs(afterSave: () -> Unit = {}) { withApi { val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) - val updated = m.controller.apiUpdateProfile(newProfile) + val updated = m.controller.apiUpdateProfile(user.remoteHostId, newProfile) if (updated != null) { val (updatedProfile, updatedContacts) = updated - m.updateCurrentUser(updatedProfile, preferences) - updatedContacts.forEach(m::updateContact) + m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) + updatedContacts.forEach { m.updateContact(user.remoteHostId, it) } currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 84ab87c65..1a5aa49eb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -99,7 +99,7 @@ fun PrivacySettingsView( fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserContactReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserContactReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { @@ -111,7 +111,7 @@ fun PrivacySettingsView( val sendRcpts = contact.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - chatModel.updateContact(contact) + chatModel.updateContact(currentUser.remoteHostId, contact) } } } @@ -122,7 +122,7 @@ fun PrivacySettingsView( fun setSendReceiptsGroups(enable: Boolean, clearOverrides: Boolean) { withApi { val mrs = UserMsgReceiptSettings(enable, clearOverrides) - chatModel.controller.apiSetUserGroupReceipts(currentUser.userId, mrs) + chatModel.controller.apiSetUserGroupReceipts(currentUser, mrs) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { @@ -134,7 +134,7 @@ fun PrivacySettingsView( val sendRcpts = groupInfo.chatSettings.sendRcpts if (sendRcpts != null && sendRcpts != enable) { groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - chatModel.updateGroup(groupInfo) + chatModel.updateGroup(currentUser.remoteHostId, groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt index f3896a3de..4e8da36a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt @@ -197,7 +197,7 @@ fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = try { - val r = m.controller.testProtoServer(server.server) + val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r } catch (e: Exception) { Log.e(TAG, "testServerConnection ${e.stackTraceToString()}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index 92246b72f..cbcb7344f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -28,7 +28,8 @@ import chat.simplex.res.MR import kotlinx.coroutines.launch @Composable -fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () -> Unit) { +fun ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { + // TODO close if remote host changes var presetServers by remember { mutableStateOf(emptyList()) } var servers by remember { mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) @@ -51,7 +52,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () } LaunchedEffect(Unit) { - val res = m.controller.getUserProtoServers(serverProtocol) + val res = m.controller.getUserProtoServers(rhId, serverProtocol) if (res != null) { currServers.value = res.protoServers presetServers = res.presetServers @@ -90,7 +91,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () ModalView( close = { if (saveDisabled.value) close() - else showUnsavedChangesAlert({ saveServers(serverProtocol, currServers, servers, m, close) }, close) + else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close) }, ) { ProtocolServersLayout( @@ -118,7 +119,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showModalCloseable { close -> - ScanProtocolServer { + ScanProtocolServer(rhId) { close() servers = servers + it m.userSMPServersUnsaved.value = servers @@ -133,7 +134,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () if (!hasAllPresets) { SectionItemView({ AlertManager.shared.hideAlert() - servers = (servers + addAllPresets(presetServers, servers, m)).sortedByDescending { it.preset } + servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset } }) { Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -155,7 +156,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () m.userSMPServersUnsaved.value = null }, saveSMPServers = { - saveServers(serverProtocol, currServers, servers, m) + saveServers(rhId, serverProtocol, currServers, servers, m) }, showServer = ::showServer, ) @@ -289,11 +290,11 @@ private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List, servers: List, m: ChatModel): Boolean = presetServers.all { hasPreset(it, servers) } ?: true -private fun addAllPresets(presetServers: List, servers: List, m: ChatModel): List { +private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { val toAdd = ArrayList() for (srv in presetServers) { if (!hasPreset(srv, servers)) { - toAdd.add(ServerCfg(srv, preset = true, tested = null, enabled = true)) + toAdd.add(ServerCfg(remoteHostId = rhId, srv, preset = true, tested = null, enabled = true)) } } return toAdd @@ -346,9 +347,9 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUpd return fs } -private fun saveServers(protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { +private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { withApi { - if (m.controller.setUserProtoServers(protocol, servers)) { + if (m.controller.setUserProtoServers(rhId, protocol, servers)) { currServers.value = servers m.userSMPServersUnsaved.value = null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index 02582ec93..ac74bd04d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -13,10 +13,10 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -expect fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) +expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) @Composable -fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { Column( Modifier .fillMaxSize() @@ -32,7 +32,7 @@ fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { QRCodeScanner { text -> val res = parseServerAddress(text) if (res != null) { - onNext(ServerCfg(text, false, null, true)) + onNext(ServerCfg(remoteHostId = rhId, text, false, null, true)) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_invalid_address), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 089ec7713..b75f52268 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -26,12 +26,12 @@ fun SetDeliveryReceiptsView(m: ChatModel) { if (currentUser != null) { withApi { try { - m.controller.apiSetAllContactReceipts(enable = true) + m.controller.apiSetAllContactReceipts(currentUser.remoteHostId, enable = true) m.currentUser.value = currentUser.copy(sendRcptsContacts = true) m.setDeliveryReceipts.value = false m.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) try { - val users = m.controller.listUsers() + val users = m.controller.listUsers(currentUser.remoteHostId) m.users.clear() m.users.addAll(users) } catch (e: Exception) { 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..7bd060e8d 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,8 @@ import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.remote.ConnectDesktopView +import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -153,8 +155,13 @@ fun SettingsLayout( } val profileHidden = rememberSaveable { mutableStateOf(false) } 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) + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, it.currentUser.value?.remoteHostId, 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) + } else { + SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) + } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index d03b75856..98989a775 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,10 +33,12 @@ import chat.simplex.res.MR @Composable fun UserAddressView( chatModel: ChatModel, + rhId: Long?, viaCreateLinkView: Boolean = false, shareViaProfile: Boolean = false, close: () -> Unit ) { + // TODO close when remote host changes val shareViaProfile = remember { mutableStateOf(shareViaProfile) } var progressIndicator by remember { mutableStateOf(false) } val onCloseHandler: MutableState<(close: () -> Unit) -> Unit> = remember { mutableStateOf({ _ -> }) } @@ -45,7 +47,7 @@ fun UserAddressView( progressIndicator = true withBGApi { try { - val u = chatModel.controller.apiSetProfileAddress(on) + val u = chatModel.controller.apiSetProfileAddress(rhId, on) if (u != null) { chatModel.updateUser(u) } @@ -67,7 +69,7 @@ fun UserAddressView( createAddress = { withApi { progressIndicator = true - val connReqContact = chatModel.controller.apiCreateUserAddress() + val connReqContact = chatModel.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { chatModel.userAddress.value = UserContactLinkRec(connReqContact) @@ -112,7 +114,7 @@ fun UserAddressView( onConfirm = { progressIndicator = true withApi { - val u = chatModel.controller.apiDeleteUserAddress() + val u = chatModel.controller.apiDeleteUserAddress(rhId) if (u != null) { chatModel.userAddress.value = null chatModel.updateUser(u) @@ -126,7 +128,7 @@ fun UserAddressView( }, saveAas = { aas: AutoAcceptState, savedAAS: MutableState -> withBGApi { - val address = chatModel.controller.userAddressAutoAccept(aas.autoAccept) + val address = chatModel.controller.userAddressAutoAccept(rhId, aas.autoAccept) if (address != null) { chatModel.userAddress.value = address savedAAS.value = aas diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index ea4ef79d4..4bc2d9241 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -37,10 +37,10 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { close, saveProfile = { displayName, fullName, image -> withApi { - val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) + val updated = chatModel.controller.apiUpdateProfile(user.remoteHostId, profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) if (updated != null) { val (newProfile, _) = updated - chatModel.updateCurrentUser(newProfile) + chatModel.updateCurrentUser(user.remoteHostId, newProfile) profile = newProfile close() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 7d3239700..ec6d4e196 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -57,7 +57,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.end.closeModals() } withBGApi { - m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) } }, removeUser = { user -> @@ -106,24 +106,24 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> withBGApi { - setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) } + setUserPrivacy(m) { m.controller.apiUnhideUser(user, pwd) } close() } } } } else { - withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value.trim()) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user, searchTextOrPassword.value.trim()) } } } }, muteUser = { user -> withBGApi { setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) - }) { m.controller.apiMuteUser(user.userId) } + }) { m.controller.apiMuteUser(user) } } }, unmuteUser = { user -> - withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } } + withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user) } } }, showHiddenProfile = { user -> ModalManager.start.showModalCloseable(true) { close -> @@ -348,14 +348,14 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de if (users.size < 2) return suspend fun deleteUser(user: User) { - m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd) + m.controller.apiDeleteUser(user, delSMPQueues, viewPwd) m.removeUser(user) } try { if (user.activeUser) { val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } if (newActive != null) { - m.controller.changeActiveUser_(newActive.userId, null) + m.controller.changeActiveUser_(newActive.remoteHostId, newActive.userId, null) deleteUser(user.copy(activeUser = false)) } } else { 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 d2f464297..616e78651 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,8 @@ File saved File not found Error saving file + Loading the file + Please, wait while the file is being loaded from the linked mobile Voice message @@ -952,6 +954,7 @@ CALLS Incognito mode EXPERIMENTAL + Use from desktop Your chat database @@ -1627,6 +1630,49 @@ 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 + (this device v%s)]]> + 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 + Unlink desktop? + Unlink + Disconnect + Disconnect desktop? + Only one device can work at the same time + Use from desktop in mobile app and scan QR code]]> + Bad desktop address + Incompatible version + Desktop app version %s is not compatible with this app. + Connection terminated + Session code + Connecting to desktop + Connect to desktop + Connected to desktop + Connected desktop + Verify code with desktop + (new)]]> + Linked desktops + Desktop devices + Linked desktop options + Scan QR code from desktop + Desktop address + Verify connections + Discover on network + Paste desktop address + Desktop + 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/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index 94e985328..cb34bdb3b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -113,7 +113,7 @@ object NtfManager { private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) { tmpDir.mkdir() - val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png")) + val newFile = File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", "png", tmpDir)) try { ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream()) newFile.absolutePath diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 7193fbe2b..92111f162 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -2,11 +2,15 @@ package chat.simplex.common.platform import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi import java.util.* +import chat.simplex.res.MR actual val appPlatform = AppPlatform.DESKTOP +actual val deviceName = generalGetString(MR.strings.desktop_device) + @Suppress("ConstantLocale") val defaultLocale: Locale = Locale.getDefault() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a6283..0f7c13186 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +actual val remoteHostsDir: File = File(dataDir.absolutePath + File.separator + "remote_hosts") + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index cce8a3ce8..c2665109f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -50,22 +50,23 @@ actual fun ActiveCallView() { val call = chatModel.activeCall.value if (call != null) { Log.d(TAG, "has active call $call") + val callRh = call.remoteHostId when (val r = apiMsg.resp) { is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) - chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } is WCallResponse.Offer -> withBGApi { - chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } is WCallResponse.Answer -> withBGApi { - chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { @@ -73,7 +74,7 @@ actual fun ActiveCallView() { if (callStatus == WebRTCCallStatus.Connected) { chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } - withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Error) { Log.d(TAG, "call status ${r.state.connectionState} not used") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt deleted file mode 100644 index 7ea2ef536..000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt +++ /dev/null @@ -1,8 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.runtime.Composable - -@Composable -actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - ScanCodeLayout(verifyCode, close) -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 711e09267..6da207856 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.ModalManager -import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index f602dd577..91efdf790 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -34,35 +34,51 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } - else -> {} + val saveIfExists = { + when (cItem.content.msgContent) { + is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false } - showMenu.value = false + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + withBGApi { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + saveIfExists() + } + } else saveIfExists() }) } -actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { - val fileSource = getLoadedFileSource(cItem.file) +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) = withBGApi { + var fileSource = getLoadedFileSource(cItem.file) + if (chatModel.connectedToRemote() && fileSource == null) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + } + if (fileSource != null) { val filePath: String = if (fileSource.cryptoArgs != null) { val tmpFile = File(tmpDir, fileSource.filePath) tmpFile.deleteOnExit() try { - decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs ?: return@withBGApi, tmpFile.absolutePath) } catch (e: Exception) { Log.e(TAG, "Unable to decrypt crypto file: " + e.stackTraceToString()) - return + return@withBGApi } tmpFile.absolutePath } else { getAppFilePath(fileSource.filePath) } - when { + when { desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) else -> clipboard.setText(AnnotatedString(filePath)) } } else { clipboard.setText(AnnotatedString(cItem.content.text)) } -} + showToast(MR.strings.copied.localized()) +}.run {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index d2fa97f86..61e0e0d3c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -44,7 +44,7 @@ actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow? { - val filePath = getLoadedFilePath(file) +actual suspend fun getLoadedImage(file: CIFile?): Pair? { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file?.loadRemoteFile(false) + filePath = getLoadedFilePath(file) + } return if (filePath != null) { try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() @@ -141,7 +150,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext)) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) // LALAL FILE IS EMPTY ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) newFile diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt index 84da0a775..6d56a7b51 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.ChatModel @Composable -actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { - PasteToConnectView(m, close) +actual fun ConnectViaLinkView(m: ChatModel, rhId: Long?, close: () -> Unit) { + // TODO this should close if remote host changes in model + PasteToConnectView(m, rhId, close) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index f202318f1..540de40a9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel @Composable -actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { +actual fun ScanToConnectView(chatModel: ChatModel, rhId: Long?, close: () -> Unit) { ConnectContactLayout( chatModel = chatModel, + rhId = rhId, incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt index 464c28631..2d436dbbf 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt @@ -4,6 +4,6 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.ServerCfg @Composable -actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { - ScanProtocolServerLayout(onNext) +actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { + ScanProtocolServerLayout(rhId, onNext) } diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 03463be06..510c9c30b 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -41,7 +41,7 @@ mySquaringBot :: User -> ChatController -> IO () mySquaringBot _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 45be23dd7..326a8728a 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -35,7 +35,7 @@ broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ ct _ -> do contactConnected ct diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index f5d95e57f..ccfc6a484 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -3,10 +3,11 @@ module Main where import Control.Concurrent (threadDelay) +import Control.Concurrent.STM.TVar (readTVarIO) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Server -import Simplex.Chat.Controller (versionNumber, versionString) +import Simplex.Chat.Controller (currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal @@ -28,10 +29,12 @@ main = do t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t else simplexChatCore terminalChatConfig opts $ \user cc -> do + rh <- readTVarIO $ currentRemoteHost cc + let cmdRH = rh -- response RemoteHost is the same as for the command itself r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone - putStrLn $ serializeChatResponse (Just user) ts tz r + putStrLn $ serializeChatResponse (rh, Just user) ts tz cmdRH r threadDelay $ chatCmdDelay opts * 1000000 welcome :: ChatOpts -> IO () diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 067780dee..46c71a796 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -83,7 +83,7 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do >>= processCommand >>= atomically . writeTBQueue sndQ output ChatClient {sndQ} = forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc atomically $ writeTBQueue sndQ ChatSrvResponse {corrId = Nothing, resp} receive ws ChatClient {rcvQ, sndQ} = forever $ do s <- WS.receiveData ws diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 09ab424cf..5a53e1a36 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -59,7 +59,7 @@ welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" - unless testing $ do + unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" pure opts @@ -68,7 +68,7 @@ directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {userId} cc = do initializeBotAddress' (not testing) cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc forM_ (crDirectoryEvent resp) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole @@ -161,7 +161,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { badRolesMsg :: GroupRolesStatus -> Maybe String badRolesMsg = \case GRSOk -> Nothing - GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" + GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" GRSContactNotOwner -> Just "You must grant directory service *admin* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" @@ -352,7 +352,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { groupRef = groupReference g srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." - whenContactIsOwner gr action = + whenContactIsOwner gr action = getGroupMember gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) @@ -494,7 +494,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { sendChatCmdStr cc cmdStr >>= \r -> do ts <- getCurrentTime tz <- getCurrentTimeZone - sendReply $ serializeChatResponse (Just user) ts tz r + sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r DCCommandError tag -> sendReply $ "Command error: " <> show tag | otherwise = sendReply "You are not allowed to use this command" where diff --git a/cabal.project b/cabal.project index 296631018..53c4ecb4c 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 20d9767c5474de083b711cc034c871af3b57f6f7 + tag: 6926b45703715659887cda166fc1f78f9ef614f9 source-repository-package type: git diff --git a/docs/rfcs/2023-09-12-remote-profile.md b/docs/rfcs/2023-09-12-remote-profile.md new file mode 100644 index 000000000..a36c1bfcf --- /dev/null +++ b/docs/rfcs/2023-09-12-remote-profile.md @@ -0,0 +1,213 @@ +# Remote profile + +## Problem + +Users want their desktop client to be in sync with profiles at their main device (presumably a mobile phone). +Due to distributed nature of SimpleX chat and comprehensive encryption it is difficult to maintain up to date multi-way synchronized presentation between devices. + +## Solution + +A typical (and expected) solution for this is running a server on a master device which will handle all the communication. +Then, additional "thin" client(s) would be able to present an interface, delegating everything else to the main. + +Fortunately, we already have such a protocol in our clients. +CLI and GUI run a text+json RPC protocol to their chat core. +CLI has a WebSocket server for it that facilitates making custom clients and bots – it won't be usable here though. + +We can run this protocol over a secure channel designed specifically for this problem. + +Then we can tweak clients to use this protocol instead of regular "local" profiles. + +## Session lifecycle + +For the sake of grounding and familiarity the roles are: +* "Mobile": a master device which stores data and does the communication. +* "Desktop": UI client attached to the master. + +1. Discovery: a user wants to attach a desktop client to their mobile. +2. Handshake: desktop and mobile establish a secure duplex session. +3. Activity: desktop sends requests and receives events from mobile and updates its presentation. +4. Restart: desktop should be able to re-eastablish channel unattended in the case of network winking out for a while. +5. Disposal: mobile can terminate the link and permanently dispose the established session. + +[![](https://mermaid.ink/img/pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E?type=png)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNq1Vs2O2jAQfpVRTq3EvgCqVtomrZZDKhWKtAcuxh7AxbFT27BCq5X2QdqX2yfpODHGQOitJ4jn88w3P98kLwU3Aotx4fDXDjXHSrK1Zc1CAzDujYVqDsxBhW7rTRuOW2a95LJl2kM1yYwwn1zZy9xeGouXiLpD1GYpFQ4DJhmgj9ATq-cnw0KHc208gtljID0i-xgq6Xg4OARzNb-7v68mY5iGXJ0HxzwqJelSa82qc0OoSUCVY5gI1F76A9gefhGBEJ-tYYIz8iQjNrKTe_JMkM5fGaMmf4J5dorUs2wVOyQv8H0KoS0BVAfSNV2fcabPDOF2XYZs1tJ5onRKZ5BOXQ7X6JFp4TZsi4ltnWe_PCbZBS0vi8M3TCnU6xu3LbrWaNdZTevhq7RULi8bhE0eN7qus-5wo1fSNsxLoyOkS5kov7_9LnsrkAcr9RqIBfgNgvO71Qqepd-AwL3kCE9PT4vi_e1P74Mp3__JSkvuHjjH1idUVt6a2e2pM5EUioQ7VmSGWgxwRuVwKOAUfyIfDFihQuqRjW1FcdbOgZiMZKtUHlN39OKPwIuJvB6BhwAI6X0wWh26OjZMalDGtB_PrwT68wksleFbFJ-W9t4bkg_uiWJ4cj4ECnVQknt3PoGdeoPTnifJ_JhSUl25YT5XXGc8jtUsTfgFIuNHsJ1-tqwNo-mBtXLUjZrCEdAPBNN1ITPHp4FNsYN8I7HcyrT4hyfiJFeSZ3259HaNuNm5vnTXnZtSEWhZ3to7_0nJSUTJS6-f_jAflhH0C9ftSPEsjlmXYtJF8tFL4paPsCSNI4Gjc7FeXZluLn5CMxXZntpyJ7X0kp7E2SuBFFmhZ3xz2qJRm2kyf6BtpA4FziikOlV4FOyZXEOa8GicP4-bb4IbcbN9MOg5le02r-6to_dMSZGZoZNoNmZZ9SajvhENPSZ8_746bvBv5mhwNBNhTdD6vWpaUEJ45atAXGPUeT7QZWJQjIqGcmBS0HfISzheFLSCGlwUY_oraAEvioV-JRzbeTM7aF6MV4wqOyp2bUgtfrRcnH4Rkr4T4uHrX2MK93E) + +### Discovery + +The expected flow is desktop initiates the discovery by generating OOB key data and shows a QR code for mobile to scan. +The mobile then scans that QR code, decodes the "attachment request" and spins up a network server. + +There is a problem here, that the desktop doesn't know where its mobile actually located. + +This can be solved in a few different ways: + +1. The desktop starts a server and encodes its local IP in the QR. Mobile then connects to it. +2. The desktop encodes its local IP, but mobile only does a minimal client legwork, only to signal its actual location. Then the sides flip. + * The legwork may entail sending UDP datagram to desktop IP with an IP of its own. + * Another option is to use a TCP "nanoprotocol" of sending a `host:port` line. +3. The mobile may start announcing itself with UDP broadcasts for the duration of the phase (bluetooth-style) using information in the QR code. +4. A desktop may create a temporary SMP queue and show its address. The mobile then submits its server data to it. + +Another option is to run the server on desktop and have mobile discover it with the help of QR code to get server identity and keys and then on the network via some protocol. Using a fixed address is suboptimal as most networks have dynamic IPs. + +### Handshake + +The aim of this phase is to establish a TLS+cryptobox session. + +TLS could be complex as we need to generate self-signed certificates on desktop (if it acts like a server). A plaintext ws connection with cryptobox encryption could be sufficient initially? + +TBD + +### Activity + +The desktop starts its chat core with a special parameter to signal that it should be using the session instead of its regular "local" database. This can be determined per user profile. + +Other than that, the client behaves like it would do with a local chat state. +Its chat core being handed a socket uses it to relay the chat protocol data. + +The mobile, starts replaying the commands it had received on its state, maintaining a single point of truth. +When a mobile receives events or replies, it mirrors them to the attached session. + +Only a subset of the chat API should be available this way. +Requests like `/_stop` or `/_db delete` should be filtered out and ignored. + +Some of the relayed commands (e.g. `/_read chat` or `/_reaction`) the mobile should apply to its own state too. + +A simpler solution could be that while desktop client is connected mobile UI is locked. When the session terminates, mobile UI gets unlocked and refreshed. + +> A tweak in protocol that would reply with an event like "accepted read of X up to Y" may remove the need for such matching and interpretation. + +### Restart + +It would be annoying to users if walking to another room and loosing WiFi connection for a few seconds would result in another QR dance. + +Therefore, the non-ephemeral part of handshake material should be reused for reconnects. + +TBD + +### Disposal + +The session may have a lifetime that a desktop or a mobile may stipulate while preparing a session. +Alternatively a mobile (or a desktop, why not) may signal that they're done here and no further activity should be going with the session parameters. + +## Proposed UX flow + +> For now, desktop and mobile roles are mutually exclusive. +> Mobile device can only host remote session, while desktop devices can only remote-control. + +### On a mobile device + +1. A user opens sidebar and clicks "use from desktop" in the "You" section, starting remote controller discovery. + * When this happens for the first time, the user must set the mobile device name, pre-filled from system device name if possible. + * UI enters "Waiting for desktop" window, which collects all the broadcasts received so far. + - + * Discovery process starts UDP broadcast listener on application port (5226). + * A datagram containing remote controller fingerprint is checked against a list of pre-registered controller devices. + - If the datagram contains no valid fingerprint, it is ignored. + - For unknown/new broadcasts a fingerprint is displayed instead. + - If the device is already known, the host establishes connection and UI transitions into "connection status" window. +2. Clicking on unknown device fingerprint in the list starts OOB handshake. + * UI enters "New device" window, displaying a fingerprint and asking to scan a QR code (or paste a link, like in the contact screen). + * A OOB data from the QR/link contains remote controller fingerprint and remote display name, which is stored in device DB. + - The OOB fingerprint must match the announce. + * Accepting the OOB automatically triggers remote controller connection and transitions UI to "connection status" window. +3. A remote session initiated with a known device, or as a result of OOB exchange. + * A "connection status" window shows registered display name and current session status. + * Chat controller attempts to establish a remote session. + - The source adddress of the datagram is used to initiate TCP connection. + - A TCP connection is made to the address discovered. + - A TLS connection is instantiated, checking for remote CA fingerprint matches the previously established. + - A HTTP2 server is started on the mobile side of the TLS connection. + - The remote controller connects and subscribes for its output queue, marking the session established. + * For the duration of the remote session, the UI remains in the status window, preventing user interaction. + - This restriction may be lifted later. + +At any time a user may click on a "cancel" button and return to the main UI. +That should fully re-initialise UI state. + +This screen should have a way to open the list of all known remote controllers (desktop devices), to allow removing them. + +*Removing* remote controller means its entry will be removed from database. +Future connection attempts with a removed desktop will be treated as with a previously unknown device. + +### On a desktop device + +1. A user opens sidebar and clicks "connect to mobile" in the "You" section. + * UI enters a "Select remote host" window asking user to pick an existing connection profile or generate a new one. + * When a new connection profile is requested by a user, a private key is generated and a new X509 root CA certificate is produced and stored in device DB. Then the desktop proceeds to the connection screen. +2. Clicking on an existing connection profile transitions UI to "connecting to remote host" window. + * For a first-time connection a QR code / link is presented, containing the fingerprint of the CA stored for the selected profile. + - After first time the QR code is hidden until a subdued "show QR code" button is clicked. This is to prevent user confusion that they have to scan the code every time. + * A new session certificate is derived from the CA. + * A TLS server is started using ephemeral session certificate. + - TLS handshake is used to authenticate desktop to a connecting mobile, proving that the announcer is indeed owns the key with the fingerprint received OOB by mobile. See below for a case for mutual authentication. + * A periodic UDP broadcast on port 5226 is started, sending the fingerprint. +3. When an incoming connection is established the UI transitions to "connected to remote host" window. + * The announcer is terminated and TCP server stops accepting new connections. + * Desktop chat controller establishes a remote session over the tls session. + * UI transitions to the "remote host" mode, shunting local profiles into background while keeping notifications coming. +4. A user may open sidebar and click "disconnect from mobile" to close the session and return to local mode. + * That should fully re-initialise UI state. + +Unlike mobile UI, removing known mobiles should happen via the same screen that shows connected mobile deivices. + +*Removing* a remote host means its entry will be removed from database and any associated files deleted (photos, voice messages, transferred files etc). + +## Caveats + +A public WiFi spot (or a specially configured home AP) may prohibit clients to connect with each other, denying them link-local connection. +n such an event, an alternative transports may be considered: +- Bluetooth link. +- USB tethering that presents an ethernet device. +- The usual NAT traversal techniques. +- Running localnet-providing VPNs. +- Routing chat traffic via SMP queues. + +Application chat traffic may end up too chatty for the link. +This may result in large power drain for both sides or unpleasant latency. +Compression protocols may be used to mitigate this. +Since we know that chat API is text+JSON Zstd compression with pre-shared dictionary may provide huge traffic savings with minimal CPU effort. + +There is a threat, that a device in the broadcast range may intercept discovery datagrams and eagerly connect to their sources. +In the case of such a "honeypot", a desktop may be tricked into receiving arbitrary contacts, messages and files from the remote host. +Some mitigations are possible for authenticating a remote host (like using OOB token as a cookie or exchanging it for a TLS client certificate). +This is intentionally left out of scope for now, until the "remote profiles" system is audited, to be resolved in a wider context. + +Requesting a list of IP addresses is problematic. +A shady app permission is required from the OS (ACCESS_NETWORK_STATE on Android). +And then the app needs to sort through all the found interfaces and guess which one would be accessible. + +## "Should-works" + +File transfer appears to be running within the chat protocol. + +UI assumes that files are available in a local storage, the access to files is not part of chat RPC. This complicates things a lot. + +Attaching multiple sessions appears to be realistic without extensive modifications. + +A headless client with a global address (e.g. VPN or TOR) may be used in a manner of IRC bouncers. + +This may also allow "thin" mobile clients (cf. traffic concerns) and browser apps. + +A backup system may be implemented by attaching a headless app to a bouncer as one of the sessions. + +The unauthenticated remote host can be considered a feature. +A use case for that may be something like a "dead drop" host that wakes up in response to any discovery broadcast. + +## Other questions + +- What to do with WebRTC/calls? + +Calls use local desktop implementation, they will use host for signalling. + +- Do we want attaching only to a subset of profiles? + +No. + +- Do we want a client to mix remote and local profiles? + +No. + +- Do we want M-to-N sessions? (follows naturally from the previous two) + +No. diff --git a/docs/rfcs/2023-10-12-remote-ui.md b/docs/rfcs/2023-10-12-remote-ui.md new file mode 100644 index 000000000..308babf5c --- /dev/null +++ b/docs/rfcs/2023-10-12-remote-ui.md @@ -0,0 +1,88 @@ +# Remote desktop / mobile implementation details + +This follows the previous RFC for [remote profiles](./2023-09-12-remote-profile.md). + +Code uses terms remote controller and remote host to designate the roles, and CLI client can support both. + +This document uses the terms "mobile" to mean remote host and "desktop" to mean remote controller, mobile apps will only support "remote host" role (via UI, and, possibly, via the compilation flag to remove this functionality from the code), and desktop will only support "remote controller" role. + +## Mobile (host) + +UX is described [here](./2023-09-12-remote-profile.md#on-a-mobile-device). + +When mobile is connected to the remote desktop it will receive no events from the core (other than remote session connection status changes), and the core will accept no commands from the UI (other than to terminate the remote session). + +As currently mobile maintains the list of connection statuses for all profiles, this state will have to be maintained in Haskell to be able to send it to the remote desktop UI when it is connected. It will also be used to update mobile UI when control is returned to the mobile. + +The switching between remote host role and local UI role should prevent the possibility of any race conditions. + +To swith to remote host role: +- UI: will show the screen with "connected" status and "cancel" button, with disabled sleep timer on iOS, as iOS app will have to stay in foregro. Android will be able to function in background in this state. +- core: stop adding events to the output queues (via some flag). +- UI: process remaining events in the output queue and stop receiving them. +- core: stop sending events to and accepting commands from UI. +- core: send current list of profiles, coversations, and connections statuses to the remote desktop. +- core: start sending events to and accepting commands from remote desktop. +- core: start adding events to remote output queue. + +To switch back to local UI role: +- core: stop adding events to the output queues. +- core: stop receiving commands from and sending events to remote desktop. +- remote desktop: receive pending events and stop processing them. +- UI: load current list of profiles, conversations, and connection statuses from the core. +- UI: start receiving events +- core: start sending events to and accepting commands from local UI. +- core: start adding events to UI local remote output queue. + +Possibly, there is a simpler way to switch, but it is important that the new events are applied to the loaded state, to avoid state being out of sync. + +## Desktop (controller) + +Desktop can either control local profiles ("local host" term is used) or remote host(s)/mobile(s). Only one host, local or remote, can be controlled at a time. It is important though to be able to receive notifications from multiple hosts, at the very least from local and mobile, as the important use case is this: + +- mobile device only has contacts and important groups to save the traffic and battery. +- desktop has large public groups. + +So while reading large public groups the users should be able to receive notifications from both mobile device and local profile(s). + +That means that while only one host can be active in desktop, multiple hosts can be connected. + +Current UI model contains: +- the list of the conversations for the chosen profile. +- the list of the user profiles. +- the statuses of connections across all profiles - this is maintained because the core does not track connection statuses. + +As the core will start maintaining the connection statuses, as a possible optimisation we could reduce the connections in the UI to only the current profile and reset it every time the profile is switched. + +In addition to the above, the UI model will need to contain the list of connected remote hosts (mobiles), so that the user can switch between them. + +Switching profile currently updates the list of conversations. If connection statuses are narrowed to the current profile only, they will have to be updated as well. + +When switching host (whether to local or to remote), the UI will have to: +- update the list of profiles +- update the list of conversations for the active profile in the host +- update connection statuses, either for all profiles or for the active profile only - TBC + +When connected to remote host, or possibly always, UI will have to use the extended FFI to indicate the host in all commands (e.g., to allow automatic file reception in inactive hosts) - as the core cannot simply assume which host is active. Probably, some of the commands (most of them) should require the host to match the currently active host in the core, file reception will not require that. + +### Onboarding and "only-remote" desktop + +Majority of the users want to use desktop in "remote-only" role, when there is no local profile. Currently, it is a global assumption that the core has an active profile for most commands. Possible solutions to that are: + +- update core to allow functioning without local profile. +- create an invisible phantom profile that won't be shown in the UI but will be used by the core to process commands related to remote hosts (connected mobiles). + +The 2nd option seems simpler. The phantom profile will use an empty string as display name, and, possibly an additional flag to mark it as phantom. Once a real profile is created this phatom one can be removed (or can be just abandoned). It might be better to block most commands for this phantom profile? + +Onboarding on desktop will need to be re-implemented to offer connecting to mobile as primary option and creating a local profile as secondary, and from the users' point of view they will not be creating any local profiles. + +## Loading files + +Currently active UI, either remote (desktop) or local (mobile), will be making the decision to auto-receive file, based on its own local settings. It seems a better trade-off than alternatives, and also allows for different auto-receive configurations based on the device. + +Forwarding received and uploading sent files to/from desktop is already implemented. + +It is still required to implement both commands API in FFI layer and, possibly, HTTP API to download files to desktop when they are: +- shown in the UI for images. +- played for voice and video messages. +- saved for any other files. diff --git a/docs/rfcs/2023-10-24-robust-discovery.md b/docs/rfcs/2023-10-24-robust-discovery.md new file mode 100644 index 000000000..ff06a12ff --- /dev/null +++ b/docs/rfcs/2023-10-24-robust-discovery.md @@ -0,0 +1,137 @@ +# Robust discovery + +## Problem + +Remote session protocol has the "discovery" phase where mobile and desktop try to find each other. + +Given how easy it is to spoof UDP datagrams extra care should be taken to avoid unauthenticated data. +In the tech spike for remote sessions, a discovery datagram contains only a TLS key fingerprint. +While this is enough to operate in a safe environment, the datagram itself should be authenticated. + +Using link-local broadcast address of `255.255.255.255` is problematic on MacOS. + +The initial implementation effort shown that discovery process better be running as a stand-alone service. +Additionally, it is desirable to run multiple service announcers in parallel from a single process. +Each announced service may be a remote controller assigned to a different remote host device, or some other site-local service entirely. + +We still want to avoid system interface enumeration due to guesswork involved in filtering them and extra permissions/entitlements required on mobile devices. + +## Solution + +* An OOB data is extended with a public key to authenticate datagrams. +* A datagram is extended with MAC envelope, service address and its tag. +* A site-local multicast group is used for announcement. +* Additional site-local multicast group is used by announcer to find its own public LAN address. + +### Datagram + +- `[4]` Version range encoding +- `[1]` 'A' (Announce) +- `[8]` systemSeconds of SystemTime encoding - does not change within session. +- `[2]` Announce counter. +- `[6]` Service address (host and port). +- `[1 + 32]` SHA256 fingerprint of CA certificate used to issue per-session TLS certificates. +- `[1 + ?]` X25519 DH key encoding to agree for per-session encryption inside TLS (hello received in response to hello from controller will contain host DH key). +- `[1 + ?]` Ed25519 public key used to sign datagram (the host also will receive it in the QR code, it should match this one). +- `[1 + ?]` Ed25519 signature signing the previous bytes. + +"Encoding" here means Encoding class. + +That gives ~250 bytes (TBC) that is well under typical MTU of ~1500. + +A site-local multicast group `224.0.0.251` is used to announce services on port `5227`. + +> The same group and port are used in mDNS (AKA bonjour/avahi/dns-sd/zeroconf) so we expect it to run with most home/SOHO access points without further configuration, although using a different port can make it ineffective. + +### OOB data + +Announcer MUST include: +- ED25519 public key used to sign announce datagrams in its OOB link/QR code (also included in datagram, so they can be validated before scanning QR code). +- the CA certificate fingerprint (also included in datagram). +- device name for better initial contact UX. + +### Discovery announcer + +> announcer is run before the controller service. +> +> Multiple announcers can send to the same group/port simultaneously. + +A typical announce interval is 1 second to balance UI responsiveness with network traffic. + +Announcer MUST first discover its own address and validate with the list of local network interfaces. + +To discover it's address it will send a datagram with this format: + +- `[4]` Version range encoding +- `[1]` 'I' (Identify) +- `[1 + 32]` Random number. + +Announcer MUST NOT announce a service for a different host. + +### Implementation + +``` +ChatController { + ... + multicastSubscribers :: TMVar Int + ... +} +``` + +Controller/host connection steps: + +1. take multicastSubscribers, if 0 subscribe to multicast group +2. increase counter and put it back. +3. send SXC0 datagrams to discover own address. +4. when received, match address to network interfaces, fail if no match after a few datagrams. +5. get free port for TCP session. +6. generate DH key for session. +7. prepare and start sending signed SXC1 datagrams. +8. when host connects to the address in the announcement, stop sending datagrams. +9. take multicastSubscribers, if 1 unsubscribe from multicast group +10. put back min(0, cnt - 1). +11. send Hello to host. +12. get Hello from host with DH key, compute shared secret to be used for remaining commands and responses. + +### Service (TCP server) + +A service submits its port/tag/payload to announcer and cancels it when a client connection is established or the service is shut down. + +A service SHOULD use system-provided dynamic port (i.e. bind to port `0`) to avoid getting "address in use" errors due to multiple service instances running or another/system service running on a designated port. + +### Discovery listener + +> TBD: A listener is most certainly a singleton service. But what would its lifetime be? +> We can run it continously for a snappier discovery and no-brainer client API. +> Or we can run it on-demand, registering there requests for discovery. + +An active listener service receives datagrams and maintains a discovery table mapping service tags and keys to source addresses. +A service key is derived from the payload, which MAY be used as-is. +Source address contains both host and port. + +Listener MUST verify datagram signature against the key it got in datagram. + +Listener MUST verify that the address in the announcement matches the source address of the datagram. + +During the first connection to the new controller: + +OOB must have the same: +- Ed25519 key used to sign datagrams. +- CA cert fingerprint. + +During the subsequent connections, these keys and CA cert fingerprint in the datagram mush match the record. + +### Service (TCP client) + +> TBD: This assumes always-on listener. + +A TCP client will use STM to wait for expected service tag and key to appear in discovery table to get its address. + +E.g. a remote host on a mobile will wait for the remote profile service with a key fingerprint from OOB. + +### Finding own address with multicast + +An host with a multicast entitlement may use it to find its own address. +Receiving your own datagram would reveal source address just as it is used in (unauthenticated) discovery tests. + +The same multicast group `224.0.0.251` is used to send "mirror" datagrams on port `5227`. diff --git a/flake.nix b/flake.nix index 44a2e287e..1bcde2c85 100644 --- a/flake.nix +++ b/flake.nix @@ -165,6 +165,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) + ]; }]; }).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; @@ -226,6 +229,9 @@ packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-android-log.patch ]; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (androidPkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; @@ -285,9 +291,13 @@ "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -298,6 +308,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -308,9 +321,13 @@ "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ + packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -321,6 +338,9 @@ extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; + packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + (pkgs.openssl.override { static = true; }) + ]; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" 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 diff --git a/package.yaml b/package.yaml index cd50f5c8a..a043d45be 100644 --- a/package.yaml +++ b/package.yaml @@ -24,15 +24,18 @@ dependencies: - constraints >= 0.12 && < 0.14 - containers == 0.6.* - cryptonite >= 0.27 && < 0.30 + - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* - email-validate == 2.3.* - exceptions == 0.10.* - filepath == 1.4.* - http-types == 0.12.* + - http2 >= 4.2.2 && < 4.3 - memory == 0.15.* - mtl == 2.2.* - network >= 3.1.2.7 && < 3.2 + - network-transport == 0.5.6 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 @@ -46,6 +49,7 @@ dependencies: - terminal == 0.2.* - text == 1.2.* - time == 1.9.* + - tls >= 1.6.0 && < 1.7 - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 1.7.* @@ -115,9 +119,11 @@ tests: - apps/simplex-directory-service/src main: Test.hs dependencies: + - QuickCheck == 2.14.* - simplex-chat - async == 2.2.* - deepseq == 1.4.* + - generic-random == 1.5.* - hspec == 2.7.* - network == 3.1.* - silently == 1.2.* diff --git a/scripts/cabal.project.local.mac b/scripts/cabal.project.local.mac index 35c10db75..dd62f1a39 100644 --- a/scripts/cabal.project.local.mac +++ b/scripts/cabal.project.local.mac @@ -1,6 +1,11 @@ ignore-project: False -- amend to point to the actual openssl location + +package simplexmq + extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include + extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib + package direct-sqlcipher extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 881e0aea2..bd2cdc1c2 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -30,16 +30,9 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* cd $root_dir mkdir dist-newstyle 2>/dev/null || true -if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then - cd dist-newstyle - curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz - $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz - cd openssl-1.1.1w - ./Configure mingw64 - make - cd ../../ -fi -openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') +scripts/desktop/prepare-openssl-windows.sh + +openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g') rm -rf $BUILD_DIR 2>/dev/null || true # Existence of this directory produces build error: cabal's bug rm -rf dist-newstyle/src/direct-sq* 2>/dev/null || true diff --git a/scripts/desktop/prepare-openssl-windows.sh b/scripts/desktop/prepare-openssl-windows.sh new file mode 100644 index 000000000..79822d3ff --- /dev/null +++ b/scripts/desktop/prepare-openssl-windows.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" + +cd $root_dir + +if [ ! -f dist-newstyle/openssl-1.1.1w/libcrypto-1_1-x64.dll ]; then + mkdir dist-newstyle 2>/dev/null || true + cd dist-newstyle + curl https://www.openssl.org/source/openssl-1.1.1w.tar.gz -o openssl.tar.gz + $WINDIR\\System32\\tar.exe -xvzf openssl.tar.gz + cd openssl-1.1.1w + ./Configure mingw64 + make + cd ../../ +fi diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2d5562ad8..8dd74b530 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."20d9767c5474de083b711cc034c871af3b57f6f7" = "13lyrd9q3qa1b2sfar1gbwxx9bmwramqqry7zj5pnr2ll2xg67s2"; + "https://github.com/simplex-chat/simplexmq.git"."6926b45703715659887cda166fc1f78f9ef614f9" = "0rw6i0hjh9adypfnfhq6h7arb9mdpc5dhq4g0gzys49qxv5cwra8"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index add61dc06..c3791ac11 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack @@ -32,10 +32,12 @@ library Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core + Simplex.Chat.Files Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages Simplex.Chat.Messages.CIContent + Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Migrations.M20220101_initial Simplex.Chat.Migrations.M20220122_v1_1 Simplex.Chat.Migrations.M20220205_chat_item_status @@ -121,6 +123,7 @@ library Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231113_group_forward + Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -128,6 +131,13 @@ library Simplex.Chat.Options Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol + Simplex.Chat.Remote + Simplex.Chat.Remote.AppVersion + Simplex.Chat.Remote.Multicast + Simplex.Chat.Remote.Protocol + Simplex.Chat.Remote.RevHTTP + Simplex.Chat.Remote.Transport + Simplex.Chat.Remote.Types Simplex.Chat.Store Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct @@ -136,6 +146,7 @@ library Simplex.Chat.Store.Messages Simplex.Chat.Store.Migrations Simplex.Chat.Store.Profiles + Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Terminal @@ -164,15 +175,18 @@ library , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -186,6 +200,7 @@ library , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* @@ -212,15 +227,18 @@ executable simplex-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -235,6 +253,7 @@ executable simplex-bot , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* @@ -261,15 +280,18 @@ executable simplex-bot-advanced , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -284,6 +306,7 @@ executable simplex-bot-advanced , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* @@ -312,15 +335,18 @@ executable simplex-broadcast-bot , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -335,6 +361,7 @@ executable simplex-broadcast-bot , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* @@ -362,15 +389,18 @@ executable simplex-chat , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network ==3.1.* + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -385,6 +415,7 @@ executable simplex-chat , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -416,15 +447,18 @@ executable simplex-directory-service , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network >=3.1.2.7 && <3.2 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -439,6 +473,7 @@ executable simplex-directory-service , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* @@ -459,9 +494,11 @@ test-suite simplex-chat-test ChatTests.Groups ChatTests.Profiles ChatTests.Utils + JSONTests MarkdownTests MobileTests ProtocolTests + RemoteTests SchemaDump ValidNames ViewTests @@ -479,7 +516,8 @@ test-suite simplex-chat-test apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* + QuickCheck ==2.14.* + , aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -490,17 +528,21 @@ test-suite simplex-chat-test , constraints >=0.12 && <0.14 , containers ==0.6.* , cryptonite >=0.27 && <0.30 + , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* , filepath ==1.4.* + , generic-random ==1.5.* , hspec ==2.7.* , http-types ==0.12.* + , http2 >=4.2.2 && <4.3 , memory ==0.15.* , mtl ==2.2.* , network ==3.1.* + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -516,6 +558,7 @@ test-suite simplex-chat-test , terminal ==0.2.* , text ==1.2.* , time ==1.9.* + , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==1.7.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 27aad8095..a1728b5dc 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -43,7 +43,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) @@ -52,12 +52,16 @@ import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol +import Simplex.Chat.Remote +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct @@ -96,13 +100,14 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version -import System.Exit (exitFailure, exitSuccess) -import System.FilePath (combine, splitExtensions, takeFileName, ()) +import System.Exit (ExitCode, exitFailure, exitSuccess) +import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) import System.Random (randomRIO) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkFinally, forkIO, mkWeakThreadId, threadDelay) +import qualified UnliftIO.Exception as E import UnliftIO.Directory import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM @@ -191,6 +196,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} firstTime = dbNew chatStore currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore agentAsync <- newTVarIO Nothing @@ -203,6 +209,12 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty + localDeviceName <- newTVarIO "" -- TODO set in config + multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 + remoteHostSessions <- atomically TM.empty + remoteHostsFolder <- newTVarIO Nothing + remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False expireCIThreads <- newTVarIO M.empty @@ -216,8 +228,10 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen contactMergeEnabled <- newTVarIO True pure ChatController - { firstTime, + { + firstTime, currentUser, + currentRemoteHost, smpAgent, agentAsync, chatStore, @@ -231,6 +245,12 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen sndFiles, rcvFiles, currentCalls, + localDeviceName, + multicastSubscribers, + remoteSessionSeq, + remoteHostSessions, + remoteHostsFolder, + remoteCtrlSession, config, filesFolder, expireCIThreads, @@ -354,7 +374,9 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do + readTVarIO remoteHostSessions >>= mapM_ (liftIO . cancelRemoteHost False . snd) + atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (liftIO . cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles @@ -370,22 +392,38 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, mapM_ hClose fs atomically $ writeTVar files M.empty -execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse -execChatCommand s = do +execChatCommand :: ChatMonad' m => Maybe RemoteHostId -> ByteString -> m ChatResponse +execChatCommand rh s = do u <- readTVarIO =<< asks currentUser case parseChatCommand s of Left e -> pure $ chatCmdError u e - Right cmd -> execChatCommand_ u cmd + Right cmd -> case rh of + Just rhId + | allowRemoteCommand cmd -> execRemoteCommand u rhId cmd s + | otherwise -> pure $ CRChatCmdError u $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand + _ -> execChatCommand_ u cmd execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse -execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) +execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd + +execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> m ChatResponse +execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s + +handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse +handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catches` ioErrors) + where + ioErrors = + [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, + E.Handler $ pure . Left . mkChatError + ] parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace +-- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -516,6 +554,10 @@ processChatCommand = \case createDirectoryIfMissing True ff asks filesFolder >>= atomically . (`writeTVar` Just ff) ok_ + SetRemoteHostsFolder rf -> do + createDirectoryIfMissing True rf + chatWriteVar remoteHostsFolder $ Just rf + ok_ APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ @@ -1802,15 +1844,15 @@ processChatCommand = \case asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "") - SendImage chatName f -> withUser $ \user -> do + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "") + SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - filePath <- toFSFilePath f - unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath} + filePath <- toFSFilePath fPath + unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview) + processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" @@ -1912,6 +1954,27 @@ processChatCommand = \case let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_ updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p} + SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_ + ListRemoteHosts -> withUser_ $ CRRemoteHostList <$> listRemoteHosts + SwitchRemoteHost rh_ -> withUser_ $ CRCurrentRemoteHost <$> switchRemoteHost rh_ + StartRemoteHost rh_ -> withUser_ $ do + (remoteHost_, inv) <- startRemoteHost rh_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv} + StopRemoteHost rh_ -> withUser_ $ closeRemoteHost rh_ >> ok_ + DeleteRemoteHost rh -> withUser_ $ deleteRemoteHost rh >> ok_ + StoreRemoteFile rh encrypted_ localPath -> withUser_ $ CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> withUser_ $ getRemoteFile rh rf >> ok_ + ConnectRemoteCtrl inv -> withUser_ $ do + (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrlURI inv + pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} + FindKnownRemoteCtrl -> withUser_ $ findKnownRemoteCtrl >> ok_ + ConfirmRemoteCtrl rcId -> withUser_ $ do + (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId + pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} + VerifyRemoteCtrlSession sessId -> withUser_ $ CRRemoteCtrlConnected <$> verifyRemoteCtrlSession (execChatCommand Nothing) sessId + StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ + ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls + DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do let versionInfo = coreVersionInfo $(simplexmqCommitQ) @@ -2180,14 +2243,14 @@ processChatCommand = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p - forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse + forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case - FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath - FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath + FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs + FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \f -> f.cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where - forward = processChatCommand . sendCommand chatName + forward path cfArgs = processChatCommand . sendCommand chatName $ CryptoFile path cfArgs getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId) getGroupAndMemberId user gName groupMemberName = withStore $ \db -> do @@ -2587,10 +2650,9 @@ startReceivingFile user fileId = do getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of Nothing -> - asks filesFolder >>= readTVarIO >>= \case - Nothing -> do - dir <- (`combine` "Downloads") <$> getHomeDirectory - ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory + chatReadVar filesFolder >>= \case + Nothing -> + getDefaultFilesFolder >>= (`uniqueCombine` fn) >>= createEmptyFile Just filesFolder -> @@ -2619,18 +2681,6 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle :: FilePath -> m Handle getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) -uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath -uniqueCombine filePath fileName = tryCombine (0 :: Int) - where - tryCombine n = - let (name, ext) = splitExtensions fileName - suffix = if n == 0 then "" else "_" <> show n - f = filePath `combine` (name <> suffix <> ext) - in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) - -getChatTempDirectory :: ChatMonad m => m FilePath -getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure - acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do subMode <- chatReadVar subscriptionMode @@ -3195,10 +3245,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity - isMember :: MemberId -> GroupInfo -> [GroupMember] -> Bool - isMember memId GroupInfo {membership} members = - sameMemberId memId membership || isJust (find (sameMemberId memId) members) - agentMsgConnStatus :: ACommand 'Agent e -> Maybe ConnStatus agentMsgConnStatus = \case CONF {} -> Just ConnRequested @@ -3476,7 +3522,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -3496,6 +3541,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem + members <- withStore' $ \db -> getGroupMembers db user gInfo intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -4886,11 +4932,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m () xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo unless (sameMemberId memId $ membership gInfo) $ - if isMember memId gInfo members - then messageError "x.grp.mem.new error: member already exists" - else do + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.new error: member already exists" + Left _ -> do newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) groupMsgToView gInfo ci @@ -4899,11 +4944,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do case memberCategory m of - GCHostMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - if isMember memId gInfo members - then messageWarning "x.grp.mem.intro ignored: member already exists" - else do + GCHostMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right _ -> messageError "x.grp.mem.intro ignored: member already exists" + Left _ -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second @@ -4929,11 +4973,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemInv :: GroupInfo -> GroupMember -> MemberId -> IntroInvitation -> m () xGrpMemInv gInfo@GroupInfo {groupId} m memId introInv = do case memberCategory m of - GCInviteeMember -> do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist" - Just reMember -> do + GCInviteeMember -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" + Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded @@ -4942,14 +4985,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole - members <- withStore' $ \db -> getGroupMembers db user gInfo - toMember <- case find (sameMemberId memId) members of - -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent - -- the situation when member does not exist is an error - -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. - -- For now, this branch compensates for the lack of delayed message delivery. - Nothing -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - Just m' -> pure m' + toMember <- + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent + -- the situation when member does not exist is an error + -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. + -- For now, this branch compensates for the lack of delayed message delivery. + Left _ -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + Right m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito @@ -4966,11 +5009,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | memberId (membership :: GroupMember) == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole - | otherwise = do - members <- withStore' $ \db -> getGroupMembers db user gInfo - case find (sameMemberId memId) members of - Just member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole - _ -> messageError "x.grp.mem.role with unknown member ID" + | otherwise = + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + Left _ -> messageError "x.grp.mem.role with unknown member ID" where changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" @@ -5024,25 +5066,26 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> m () xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId msg brokerTs = do - members <- withStore' $ \db -> getGroupMembers db user gInfo if memberId (membership :: GroupMember) == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo -- member records are not deleted to keep history + members <- withStore' $ \db -> getGroupMembers db user gInfo deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m - else case find (sameMemberId memId) members of - Nothing -> messageError "x.grp.mem.del with unknown member ID" - Just member@GroupMember {groupMemberId, memberProfile} -> - checkRole member $ do - -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - deleteMemberConnection user member - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user member - deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} + else + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case + Left _ -> messageError "x.grp.mem.del with unknown member ID" + Right member@GroupMember {groupMemberId, memberProfile} -> + checkRole member $ do + -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave + deleteMemberConnection user member + -- undeleted "member connected" chat item will prevent deletion of member record + deleteOrUpdateMemberRecord user member + deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = @@ -5411,9 +5454,6 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) liftIO $ mapM_ hClose h_ `catchAll_` pure () -throwChatError :: ChatMonad m => ChatErrorType -> m a -throwChatError = throwError . ChatError - deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () deleteMembersConnections user members = do let memberConns = @@ -5843,6 +5883,9 @@ withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) +withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse +withUser_ = withUser . const + withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse withUserId userId action = withUser $ \user -> do checkSameUser userId user @@ -5859,12 +5902,6 @@ waitChatStarted = do agentStarted <- asks agentAsync atomically $ readTVar agentStarted >>= \a -> unless (isJust a) retry -withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a -withAgent action = - asks smpAgent - >>= runExceptT . action - >>= liftEither . first (`ChatErrorAgent` Nothing) - chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5903,6 +5940,7 @@ chatCommandP = "/_resubscribe all" $> ResubscribeAllConnections, "/_temp_folder " *> (SetTempFolder <$> filePath), ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), + "/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), @@ -6000,14 +6038,14 @@ chatCommandP = "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), - "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> textP)), - "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> textP)), + "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), + "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), "/code " *> char_ '@' *> (GetContactCode <$> displayName), "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> textP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> textP)), + "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), "/enable " *> char_ '@' *> (EnableContact <$> displayName), "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, @@ -6058,7 +6096,7 @@ chatCommandP = "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), - ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), SendMessage <$> chatNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), @@ -6077,8 +6115,8 @@ chatCommandP = "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), "/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP), - ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath), - ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath), + ("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> cryptoFileP), + ("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> cryptoFileP), ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), @@ -6121,6 +6159,22 @@ chatCommandP = "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/set device name " *> (SetLocalDeviceName <$> textP), + -- "/create remote host" $> CreateRemoteHost, + "/list remote hosts" $> ListRemoteHosts, + "/switch remote host " *> (SwitchRemoteHost <$> ("local" $> Nothing <|> (Just <$> A.decimal))), + "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False))))), + "/stop remote host " *> (StopRemoteHost <$> ("new" $> RHNew <|> RHId <$> A.decimal)), + "/delete remote host " *> (DeleteRemoteHost <$> A.decimal), + "/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath), + "/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP), + "/connect remote ctrl " *> (ConnectRemoteCtrl <$> strP), + "/find remote ctrl" $> FindKnownRemoteCtrl, + "/confirm remote ctrl " *> (ConfirmRemoteCtrl <$> A.decimal), + "/verify remote ctrl " *> (VerifyRemoteCtrlSession <$> textP), + "/list remote ctrls" $> ListRemoteCtrls, + "/stop remote ctrl" $> StopRemoteCtrl, + "/delete remote ctrl " *> (DeleteRemoteCtrl <$> A.decimal), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -6184,9 +6238,14 @@ chatCommandP = fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) + verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ') msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP + cryptoFileP = do + cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP) + path <- filePath + pure $ CryptoFile path cfArgs memberRole = A.choice [ " owner" $> GROwner, diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index ace0bcaa6..80307f491 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Archive importArchive, deleteStorage, sqlCipherExport, + archiveFilesFolder, ) where diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index df9c66cee..4c0d37605 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -25,7 +25,7 @@ chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatControl chatBotRepl welcome answer _user cc = do initializeBotAddress cc race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc + (_, _, resp) <- atomically . readTBQueue $ outputQ cc case resp of CRContactConnected _ contact _ -> do contactConnected contact diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 7a738512b..313442838 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,18 +1,18 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} module Simplex.Chat.Call where -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as J +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -20,12 +20,11 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) data Call = Call { contactId :: ContactId, @@ -47,11 +46,7 @@ data CallStateTag | CSTCallOfferSent | CSTCallOfferReceived | CSTCallNegotiated - deriving (Show, Generic) - -instance ToJSON CallStateTag where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall" + deriving (Show) callStateTag :: CallState -> CallStateTag callStateTag = \case @@ -90,21 +85,7 @@ data CallState peerCallSession :: WebRTCSession, sharedKey :: Maybe C.Key } - deriving (Show, Generic) - --- database representation -instance FromJSON CallState where - parseJSON = J.genericParseJSON $ singleFieldJSON fstToLower - -instance ToJSON CallState where - toJSON = J.genericToJSON $ singleFieldJSON fstToLower - toEncoding = J.genericToEncoding $ singleFieldJSON fstToLower - -instance ToField CallState where - toField = toField . encodeJSON - -instance FromField CallState where - fromField = fromTextField_ decodeJSON + deriving (Show) newtype CallId = CallId ByteString deriving (Eq, Show) @@ -132,17 +113,13 @@ data RcvCallInvitation = RcvCallInvitation sharedKey :: Maybe C.Key, callTs :: UTCTime } - deriving (Show, Generic) - -instance ToJSON RcvCallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CallType = CallType { media :: CallMedia, capabilities :: CallCapabilities } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) defaultCallType :: CallType defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} @@ -150,104 +127,54 @@ defaultCallType = CallType CMVideo $ CallCapabilities {encryption = True} encryptedCall :: CallType -> Bool encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encryption -instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions - -- | * Types for chat protocol data CallInvitation = CallInvitation { callType :: CallType, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON CallInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallMedia = CMAudio | CMVideo - deriving (Eq, Show, Generic) - -instance FromJSON CallMedia where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM" - -instance ToJSON CallMedia where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM" + deriving (Eq, Show) data CallCapabilities = CallCapabilities { encryption :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallCapabilities where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallOffer = CallOffer { callType :: CallType, rtcSession :: WebRTCSession, callDhPubKey :: Maybe C.PublicKeyX25519 } - deriving (Eq, Show, Generic) - -instance FromJSON CallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON CallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data WebRTCCallOffer = WebRTCCallOffer { callType :: CallType, rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic) - -instance FromJSON WebRTCCallOffer where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON WebRTCCallOffer where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CallAnswer = CallAnswer { rtcSession :: WebRTCSession } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallAnswer where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallExtraInfo = CallExtraInfo { rtcExtraInfo :: WebRTCExtraInfo } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCSession = WebRTCSession { rtcSession :: Text, -- LZW compressed JSON encoding of offer or answer rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCSession where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCExtraInfo = WebRTCExtraInfo { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON WebRTCExtraInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data WebRTCCallStatus = WCSConnecting | WCSConnected | WCSDisconnected | WCSFailed deriving (Show) @@ -265,3 +192,37 @@ instance StrEncoding WebRTCCallStatus where "disconnected" -> pure WCSDisconnected "failed" -> pure WCSFailed _ -> fail "bad WebRTCCallStatus" + +$(J.deriveJSON (enumJSON $ dropPrefix "CSTCall") ''CallStateTag) + +$(J.deriveJSON (enumJSON $ dropPrefix "CM") ''CallMedia) + +$(J.deriveJSON defaultJSON ''CallCapabilities) + +$(J.deriveJSON defaultJSON ''CallType) + +$(J.deriveJSON defaultJSON ''CallInvitation) + +$(J.deriveJSON defaultJSON ''WebRTCSession) + +$(J.deriveJSON defaultJSON ''CallOffer) + +$(J.deriveJSON defaultJSON ''WebRTCCallOffer) + +$(J.deriveJSON defaultJSON ''CallAnswer) + +$(J.deriveJSON defaultJSON ''WebRTCExtraInfo) + +$(J.deriveJSON defaultJSON ''CallExtraInfo) + +-- database representation +$(J.deriveJSON (singleFieldJSON fstToLower) ''CallState) + +instance ToField CallState where + toField = toField . encodeJSON + +instance FromField CallState where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''RcvCallInvitation) + diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7c67cd9e5..7533649c6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,7 +1,7 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -12,6 +12,7 @@ {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Controller where @@ -24,11 +25,14 @@ import Control.Monad.Reader import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) +import Data.Constraint (Dict (..)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -37,7 +41,6 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) -import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -46,6 +49,8 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.AppVersion +import Simplex.Chat.Remote.Types import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -62,13 +67,16 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) -import Simplex.Messaging.Transport (simplexMQVersion) +import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) +import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) import UnliftIO.STM @@ -159,6 +167,7 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), + currentRemoteHost :: TVar (Maybe RemoteHostId), firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), @@ -166,13 +175,19 @@ data ChatController = ChatController chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, - outputQ :: TBQueue (Maybe CorrId, ChatResponse), + outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, + localDeviceName :: TVar Text, + multicastSubscribers :: TMVar Int, + remoteSessionSeq :: TVar Int, + remoteHostSessions :: TMap RHKey (SessionSeq, RemoteHostSession), -- All the active remote hosts + remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data + remoteCtrlSession :: TVar (Maybe (SessionSeq, RemoteCtrlSession)), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, expireCIThreads :: TMap UserId (Maybe (Async ())), @@ -188,11 +203,7 @@ data ChatController = ChatController } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase - deriving (Show, Generic) - -instance ToJSON HelpSection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" + deriving (Show) data ChatCommand = ShowActiveUser @@ -222,6 +233,7 @@ data ChatCommand | ResubscribeAllConnections | SetTempFolder FilePath | SetFilesFolder FilePath + | SetRemoteHostsFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool @@ -392,8 +404,8 @@ data ChatCommand | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text | ShowLiveItems Bool - | SendFile ChatName FilePath - | SendImage ChatName FilePath + | SendFile ChatName CryptoFile + | SendImage ChatName CryptoFile | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath @@ -411,6 +423,21 @@ data ChatCommand | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) + | SetLocalDeviceName Text + | ListRemoteHosts + | StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host + | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host + | StopRemoteHost RHKey -- ^ Shut down a running session + | DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data + | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} + | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} + | ConnectRemoteCtrl RCSignedInvitation -- ^ Connect new or existing controller via OOB data + | FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers + | ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller + | VerifyRemoteCtrlSession Text -- ^ Verify remote controller session + | ListRemoteCtrls + | StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session + | DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | QuitChat | ShowVersion | DebugLocks @@ -420,6 +447,45 @@ data ChatCommand | GetAgentSubsDetails deriving (Show) +allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal +allowRemoteCommand = \case + StartChat {} -> False + APIStopChat -> False + APIActivateChat -> False + APISuspendChat _ -> False + QuitChat -> False + SetTempFolder _ -> False + SetFilesFolder _ -> False + SetRemoteHostsFolder _ -> False + APISetXFTPConfig _ -> False + APISetEncryptLocalFiles _ -> False + APIExportArchive _ -> False + APIImportArchive _ -> False + ExportArchive -> False + APIDeleteStorage -> False + APIStorageEncryption _ -> False + APISetNetworkConfig _ -> False + APIGetNetworkConfig -> False + SetLocalDeviceName _ -> False + ListRemoteHosts -> False + StartRemoteHost _ -> False + SwitchRemoteHost {} -> False + StoreRemoteFile {} -> False + GetRemoteFile {} -> False + StopRemoteHost _ -> False + DeleteRemoteHost _ -> False + ConnectRemoteCtrl {} -> False + FindKnownRemoteCtrl -> False + ConfirmRemoteCtrl _ -> False + VerifyRemoteCtrlSession {} -> False + ListRemoteCtrls -> False + StopRemoteCtrl -> False + DeleteRemoteCtrl _ -> False + ExecChatStoreSQL _ -> False + ExecAgentStoreSQL _ -> False + SlowSQLQueries -> False + _ -> True + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -589,6 +655,20 @@ data ChatResponse | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} + | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} + | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text} + | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} + | CRNewRemoteHost {remoteHost :: RemoteHostInfo} + | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} + | CRRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId} + | CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile} + | CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]} + | CRRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo} -- registered fingerprint, may connect + | CRRemoteCtrlConnecting {remoteCtrl_ :: Maybe RemoteCtrlInfo, ctrlAppInfo :: CtrlAppInfo, appVersion :: AppVersion} + | CRRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} + | CRRemoteCtrlConnected {remoteCtrl :: RemoteCtrlInfo} + | CRRemoteCtrlStopped | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} @@ -604,7 +684,32 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRTimedAction {action :: String, durationMilliseconds :: Int64} - deriving (Show, Generic) + deriving (Show) + +-- some of these can only be used as command responses +allowRemoteEvent :: ChatResponse -> Bool +allowRemoteEvent = \case + CRChatStarted -> False + CRChatRunning -> False + CRChatStopped -> False + CRChatSuspended -> False + CRRemoteHostList _ -> False + CRCurrentRemoteHost _ -> False + CRRemoteHostStarted {} -> False + CRRemoteHostSessionCode {} -> False + CRNewRemoteHost _ -> False + CRRemoteHostConnected _ -> False + CRRemoteHostStopped _ -> False + CRRemoteFileStored {} -> False + CRRemoteCtrlList _ -> False + CRRemoteCtrlFound _ -> False + CRRemoteCtrlConnecting {} -> False + CRRemoteCtrlSessionCode {} -> False + CRRemoteCtrlConnected _ -> False + CRRemoteCtrlStopped -> False + CRSQLResult _ -> False + CRSlowSQLQueries {} -> False + _ -> True logResponseToFile :: ChatResponse -> Bool logResponseToFile = \case @@ -625,30 +730,18 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -instance ToJSON ChatResponse where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" - data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} | CPGroupLink {groupLinkPlan :: GroupLinkPlan} - deriving (Show, Generic) - -instance ToJSON ConnectionPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + deriving (Show) data InvitationLinkPlan = ILPOk | ILPOwnLink | ILPConnecting {contact_ :: Maybe Contact} | ILPKnown {contact :: Contact} - deriving (Show, Generic) - -instance ToJSON InvitationLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + deriving (Show) data ContactAddressPlan = CAPOk @@ -657,11 +750,7 @@ data ContactAddressPlan | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} | CAPContactViaAddress {contact :: Contact} - deriving (Show, Generic) - -instance ToJSON ContactAddressPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + deriving (Show) data GroupLinkPlan = GLPOk @@ -669,11 +758,7 @@ data GroupLinkPlan | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} - deriving (Show, Generic) - -instance ToJSON GroupLinkPlan where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + deriving (Show) connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case @@ -711,12 +796,15 @@ instance StrEncoding AgentQueueId where strDecode s = AgentQueueId <$> strDecode s strP = AgentQueueId <$> strP +instance FromJSON AgentQueueId where + parseJSON = strParseJSON "AgentQueueId" + instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} - deriving (Show, Generic, FromJSON) + deriving (Show) data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) @@ -727,25 +815,17 @@ data UserProtoServers p = UserProtoServers protoServers :: NonEmpty (ServerCfg p), presetServers :: NonEmpty (ProtoServerWithAuth p) } - deriving (Show, Generic) - -instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) -instance ToJSON AUserProtoServers where - toJSON (AUPS s) = J.genericToJSON J.defaultOptions s - toEncoding (AUPS s) = J.genericToEncoding J.defaultOptions s - deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} - deriving (Show, Generic, FromJSON) + deriving (Show) data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} - deriving (Show, Generic, FromJSON) + deriving (Show) newtype DBEncryptionKey = DBEncryptionKey String deriving (Show) @@ -763,41 +843,25 @@ data ContactSubStatus = ContactSubStatus { contact :: Contact, contactError :: Maybe ChatError } - deriving (Show, Generic) - -instance ToJSON ContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data MemberSubStatus = MemberSubStatus { member :: GroupMember, memberError :: Maybe ChatError } - deriving (Show, Generic) - -instance ToJSON MemberSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserContactSubStatus = UserContactSubStatus { userContact :: UserContact, userContactError :: Maybe ChatError } - deriving (Show, Generic) - -instance ToJSON UserContactSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data PendingSubStatus = PendingSubStatus { connection :: PendingContactConnection, connError :: Maybe ChatError } - deriving (Show, Generic) - -instance ToJSON PendingSubStatus where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data UserProfileUpdateSummary = UserProfileUpdateSummary { notChanged :: Int, @@ -805,16 +869,14 @@ data UserProfileUpdateSummary = UserProfileUpdateSummary updateFailures :: Int, changedContacts :: [Contact] } - deriving (Show, Generic) - -instance ToJSON UserProfileUpdateSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ComposedMessage = ComposedMessage { fileSource :: Maybe CryptoFile, quotedItemId :: Maybe ChatItemId, msgContent :: MsgContent } - deriving (Show, Generic) + deriving (Show) -- This instance is needed for backward compatibility, can be removed in v6.0 instance FromJSON ComposedMessage where @@ -829,26 +891,16 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) -instance ToJSON ComposedMessage where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data XFTPFileConfig = XFTPFileConfig { minFileSize :: Integer } - deriving (Show, Generic, FromJSON) + deriving (Show) defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -instance ToJSON XFTPFileConfig where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} - deriving (Show, Generic) - -instance ToJSON NtfMsgInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} @@ -858,25 +910,19 @@ data SwitchProgress = SwitchProgress switchPhase :: SwitchPhase, connectionStats :: ConnectionStats } - deriving (Show, Generic) - -instance ToJSON SwitchProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data RatchetSyncProgress = RatchetSyncProgress { ratchetSyncStatus :: RatchetSyncState, connectionStats :: ConnectionStats } - deriving (Show, Generic) - -instance ToJSON RatchetSyncProgress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ParsedServerAddress = ParsedServerAddress { serverAddress :: Maybe ServerAddress, parseError :: String } - deriving (Show, Generic) - -instance ToJSON ParsedServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ServerAddress = ServerAddress { serverProtocol :: AProtocolType, @@ -885,9 +931,7 @@ data ServerAddress = ServerAddress keyHash :: String, basicAuth :: String } - deriving (Show, Generic) - -instance ToJSON ServerAddress where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data TimedMessagesEnabled = TMEEnableSetTTL Int @@ -909,33 +953,27 @@ data CoreVersionInfo = CoreVersionInfo simplexmqVersion :: String, simplexmqCommit :: String } - deriving (Show, Generic) - -instance ToJSON CoreVersionInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data SendFileMode = SendFileSMP (Maybe InlineFileMode) | SendFileXFTP - deriving (Show, Generic) + deriving (Show) data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } - deriving (Show, Generic) - -instance ToJSON SlowSQLQuery where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ChatError = ChatError {errorType :: ChatErrorType} | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} - deriving (Show, Exception, Generic) - -instance ToJSON ChatError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "Chat" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat" + | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} + | ChatErrorRemoteHost {rhKey :: RHKey, remoteHostError :: RemoteHostError} + deriving (Show, Exception) data ChatErrorType = CENoActiveUser @@ -1015,11 +1053,7 @@ data ChatErrorType | CEPeerChatVRangeIncompatible | CEInternalError {message :: String} | CEException {message :: String} - deriving (Show, Exception, Generic) - -instance ToJSON ChatErrorType where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE" + deriving (Show, Exception) data DatabaseError = DBErrorEncrypted @@ -1027,30 +1061,100 @@ data DatabaseError | DBErrorNoFile {dbFile :: String} | DBErrorExport {sqliteError :: SQLiteError} | DBErrorOpen {sqliteError :: SQLiteError} - deriving (Show, Exception, Generic) - -instance ToJSON DatabaseError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB" + deriving (Show, Exception) data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String - deriving (Show, Exception, Generic) - -instance ToJSON SQLiteError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite" + deriving (Show, Exception) throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteHostError + = RHEMissing -- ^ No remote session matches this identifier + | RHEInactive -- ^ A session exists, but not active + | RHEBusy -- ^ A session is already running + | RHETimeout + | RHEBadState -- ^ Illegal state transition + | RHEBadVersion {appVersion :: AppVersion} + | RHELocalCommand -- ^ Command not allowed for remote execution + | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? + | RHEProtocolError RemoteProtocolError + deriving (Show, Exception) + +-- TODO review errors, some of it can be covered by HTTP2 errors +data RemoteCtrlError + = RCEInactive -- ^ No session is running + | RCEBadState -- ^ A session is in a wrong state for the current operation + | RCEBusy -- ^ A session is already running + | RCETimeout + | RCENoKnownControllers -- ^ No previously-contacted controllers to discover + | RCEBadController -- ^ Attempting to confirm a found controller with another ID + | RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller + | RCEBadInvitation + | RCEBadVersion {appVersion :: AppVersion} + | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used + | RCEProtocolError {protocolError :: RemoteProtocolError} + deriving (Show, Exception) + data ArchiveError = AEImport {chatError :: ChatError} | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) + deriving (Show, Exception) -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +-- | Host (mobile) side of transport to process remote commands and forward notifications +data RemoteCtrlSession + = RCSessionStarting + | RCSessionSearching + { action :: Async (), + foundCtrl :: TMVar (RemoteCtrl, RCVerifiedInvitation) + } + | RCSessionConnecting + { remoteCtrlId_ :: Maybe RemoteCtrlId, + rcsClient :: RCCtrlClient, + rcsWaitSession :: Async () + } + | RCSessionPendingConfirmation + { remoteCtrlId_ :: Maybe RemoteCtrlId, + ctrlDeviceName :: Text, + rcsClient :: RCCtrlClient, + tls :: TLS, + sessionCode :: Text, + rcsWaitSession :: Async (), + rcsWaitConfirmation :: TMVar (Either RCErrorType (RCCtrlSession, RCCtrlPairing)) + } + | RCSessionConnected + { remoteCtrlId :: RemoteCtrlId, + rcsClient :: RCCtrlClient, + tls :: TLS, + rcsSession :: RCCtrlSession, + http2Server :: Async (), + remoteOutputQ :: TBQueue ChatResponse + } + +data RemoteCtrlSessionState + = RCSStarting + | RCSSearching + | RCSConnecting + | RCSPendingConfirmation {sessionCode :: Text} + | RCSConnected {sessionCode :: Text} + deriving (Show) + +rcsSessionState :: RemoteCtrlSession -> RemoteCtrlSessionState +rcsSessionState = \case + RCSessionStarting -> RCSStarting + RCSessionSearching {} -> RCSSearching + RCSessionConnecting {} -> RCSConnecting + RCSessionPendingConfirmation {tls} -> RCSPendingConfirmation {sessionCode = tlsSessionCode tls} + RCSessionConnected {tls} -> RCSConnected {sessionCode = tlsSessionCode tls} + +-- | UI-accessible remote controller information +data RemoteCtrlInfo = RemoteCtrlInfo + { remoteCtrlId :: RemoteCtrlId, + ctrlDeviceName :: Text, + sessionState :: Maybe RemoteCtrlSessionState + } + deriving (Show) type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) @@ -1084,6 +1188,10 @@ chatFinally :: ChatMonad m => m a -> m b -> m a chatFinally = allFinally mkChatError {-# INLINE chatFinally #-} +onChatError :: ChatMonad m => m a -> m b -> m a +a `onChatError` onErr = a `catchChatError` \e -> onErr >> throwError e +{-# INLINE onChatError #-} + mkChatError :: SomeException -> ChatError mkChatError = ChatError . CEException . show {-# INLINE mkChatError #-} @@ -1091,10 +1199,20 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError +throwChatError :: ChatMonad m => ChatErrorType -> m a +throwChatError = throwError . ChatError + +-- | Emit local events. toView :: ChatMonad' m => ChatResponse -> m () toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) + localQ <- asks outputQ + session <- asks remoteCtrlSession + atomically $ + readTVar session >>= \case + Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> + writeTBQueue remoteOutputQ event + -- TODO potentially, it should hold some events while connecting + _ -> writeTBQueue localQ (Nothing, Nothing, event) withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a withStore' action = withStore $ liftIO . action @@ -1122,3 +1240,94 @@ withStoreCtx ctx_ action = do where handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr + +withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a +withAgent action = + asks smpAgent + >>= runExceptT . action + >>= liftEither . first (`ChatErrorAgent` Nothing) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RemoteCtrlError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SQLite") ''SQLiteError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) + +$(JQ.deriveJSON defaultJSON ''ContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''MemberSubStatus) + +$(JQ.deriveJSON defaultJSON ''UserContactSubStatus) + +$(JQ.deriveJSON defaultJSON ''PendingSubStatus) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "AE") ''ArchiveError) + +$(JQ.deriveJSON defaultJSON ''UserProfileUpdateSummary) + +$(JQ.deriveJSON defaultJSON ''NtfMsgInfo) + +$(JQ.deriveJSON defaultJSON ''SwitchProgress) + +$(JQ.deriveJSON defaultJSON ''RatchetSyncProgress) + +$(JQ.deriveJSON defaultJSON ''ServerAddress) + +$(JQ.deriveJSON defaultJSON ''ParsedServerAddress) + +$(JQ.deriveJSON defaultJSON ''CoreVersionInfo) + +$(JQ.deriveJSON defaultJSON ''SlowSQLQuery) + +instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) + +instance ProtocolTypeI p => FromJSON (UserProtoServers p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) + +instance ProtocolTypeI p => ToJSON (UserProtoServers p) where + toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) + +instance FromJSON AUserProtoServers where + parseJSON v = J.withObject "AUserProtoServers" parse v + where + parse o = do + AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" + case userProtocol p of + Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v + Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p + +instance ToJSON AUserProtoServers where + toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s + toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) + +$(JQ.deriveJSON defaultJSON ''RemoteCtrlInfo) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) + +$(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) + +$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) + +$(JQ.deriveJSON defaultJSON ''XFTPFileConfig) + +$(JQ.deriveToJSON defaultJSON ''ComposedMessage) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 870779cfd..c5eb19f28 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -40,7 +40,7 @@ runSimplexChat ChatOpts {maintenance} u cc chat waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO ChatResponse -sendChatCmdStr cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc +sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc diff --git a/src/Simplex/Chat/Files.hs b/src/Simplex/Chat/Files.hs new file mode 100644 index 000000000..845b237cd --- /dev/null +++ b/src/Simplex/Chat/Files.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} + +module Simplex.Chat.Files where + +import Control.Monad.IO.Class +import Simplex.Chat.Controller +import Simplex.Messaging.Util (ifM) +import System.FilePath (splitExtensions, combine) +import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist) + +uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath +uniqueCombine fPath fName = tryCombine (0 :: Int) + where + tryCombine n = + let (name, ext) = splitExtensions fName + suffix = if n == 0 then "" else "_" <> show n + f = fPath `combine` (name <> suffix <> ext) + in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) + +getChatTempDirectory :: ChatMonad m => m FilePath +getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure + +getDefaultFilesFolder :: ChatMonad m => m FilePath +getDefaultFilesFolder = do + dir <- (`combine` "Downloads") <$> getHomeDirectory + ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 969c7c2b5..f992b4574 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,8 +1,9 @@ -{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# HLINT ignore "Use newtype instead of data" #-} @@ -10,8 +11,9 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) -import Data.Aeson (ToJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A import Data.Char (isDigit, isPunctuation) @@ -26,12 +28,11 @@ import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import GHC.Generics import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types @@ -51,14 +52,10 @@ data Format | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone - deriving (Eq, Show, Generic) + deriving (Eq, Show) data SimplexLinkType = XLContact | XLInvitation | XLGroup - deriving (Eq, Show, Generic) - -instance ToJSON SimplexLinkType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "XL" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "XL" + deriving (Eq, Show) colored :: Color -> Format colored = Colored . FormatColor @@ -66,10 +63,6 @@ colored = Colored . FormatColor markdown :: Format -> Text -> Markdown markdown = Markdown . Just -instance ToJSON Format where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower - instance Semigroup Markdown where m <> (Markdown _ "") = m (Markdown _ "") <> m = m @@ -91,6 +84,18 @@ instance IsString Markdown where fromString = unmarked . T.pack newtype FormatColor = FormatColor Color deriving (Eq, Show) +instance FromJSON FormatColor where + parseJSON = J.withText "FormatColor" $ fmap FormatColor . \case + "red" -> pure Red + "green" -> pure Green + "blue" -> pure Blue + "yellow" -> pure Yellow + "cyan" -> pure Cyan + "magenta" -> pure Magenta + "black" -> pure Black + "white" -> pure White + unexpected -> fail $ "unexpected FormatColor: " <> show unexpected + instance ToJSON FormatColor where toJSON (FormatColor c) = case c of Red -> "red" @@ -103,10 +108,7 @@ instance ToJSON FormatColor where White -> "white" data FormattedText = FormattedText {format :: Maybe Format, text :: Text} - deriving (Eq, Show, Generic) - -instance ToJSON FormattedText where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) instance IsString FormattedText where fromString = FormattedText Nothing . T.pack @@ -114,11 +116,6 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} - deriving (Generic) - -instance ToJSON ParsedMarkdown where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} unmarked :: Text -> Markdown unmarked = Markdown Nothing @@ -129,7 +126,7 @@ parseMaybeMarkdownList s | otherwise = Just . reverse $ foldl' acc [] ml where ml = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s - acc [] m = [m] + acc [] m = [m] acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') | f == f' = FormattedText f (t <> t') : ms' | otherwise = ft : ms @@ -242,3 +239,11 @@ markdownP = mconcat <$> A.many' fragmentP linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact + +$(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''Format) + +$(JQ.deriveJSON defaultJSON ''FormattedText) + +$(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index a00939326..709deeb05 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -9,17 +8,20 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages where import Control.Applicative ((<|>)) -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON, ToJSON, (.:)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Char (isSpace) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) @@ -30,7 +32,6 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol @@ -40,19 +41,17 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection - deriving (Eq, Show, Ord, Generic) + deriving (Eq, Show, Ord) data ChatName = ChatName {chatType :: ChatType, chatName :: Text} - deriving (Show, Generic) + deriving (Show) -instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions - -chatTypeStr :: ChatType -> String +chatTypeStr :: ChatType -> Text chatTypeStr = \case CTDirect -> "@" CTGroup -> "#" @@ -60,15 +59,11 @@ chatTypeStr = \case CTContactConnection -> ":" chatNameStr :: ChatName -> String -chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name +chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name data ChatRef = ChatRef ChatType Int64 deriving (Eq, Show, Ord) -instance ToJSON ChatType where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CT" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CT" - data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect GroupChat :: GroupInfo -> ChatInfo 'CTGroup @@ -107,11 +102,11 @@ data JSONChatInfo | JCInfoGroup {groupInfo :: GroupInfo} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} - deriving (Generic) -instance ToJSON JSONChatInfo where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCInfo" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCInfo" +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) + +instance ChatTypeI c => FromJSON (ChatInfo c) where + parseJSON v = (\(AChatInfo _ c) -> checkChatType c) <$?> J.parseJSON v instance ToJSON (ChatInfo c) where toJSON = J.toJSON . jsonChatInfo @@ -124,10 +119,20 @@ jsonChatInfo = \case ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c -data AChatInfo = forall c. AChatInfo (SChatType c) (ChatInfo c) +data AChatInfo = forall c. ChatTypeI c => AChatInfo (SChatType c) (ChatInfo c) deriving instance Show AChatInfo +jsonAChatInfo :: JSONChatInfo -> AChatInfo +jsonAChatInfo = \case + JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c + JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g + JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g + JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c + +instance FromJSON AChatInfo where + parseJSON v = jsonAChatInfo <$> J.parseJSON v + instance ToJSON AChatInfo where toJSON (AChatInfo _ c) = J.toJSON c toEncoding (AChatInfo _ c) = J.toEncoding c @@ -141,11 +146,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem reactions :: [CIReactionCount], file :: Maybe (CIFile d) } - deriving (Show, Generic) - -instance MsgDirectionI d => ToJSON (ChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) isMention :: ChatItem c d -> Bool isMention ChatItem {chatDir, quotedItem} = case chatDir of @@ -168,20 +169,16 @@ data CIDirection (c :: ChatType) (d :: MsgDirection) where deriving instance Show (CIDirection c d) +data CCIDirection c = forall d. MsgDirectionI d => CCID (SMsgDirection d) (CIDirection c d) + +data ACIDirection = forall c d. (ChatTypeI c, MsgDirectionI d) => ACID (SChatType c) (SMsgDirection d) (CIDirection c d) + data JSONCIDirection = JCIDirectSnd | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} - deriving (Generic, Show) - -instance ToJSON JSONCIDirection where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON (CIDirection c d) where - toJSON = J.toJSON . jsonCIDirection - toEncoding = J.toEncoding . jsonCIDirection + deriving (Show) jsonCIDirection :: CIDirection c d -> JSONCIDirection jsonCIDirection = \case @@ -190,19 +187,20 @@ jsonCIDirection = \case CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m -data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} - deriving (Show, Generic) +jsonACIDirection :: JSONCIDirection -> ACIDirection +jsonACIDirection = \case + JCIDirectSnd -> ACID SCTDirect SMDSnd CIDirectSnd + JCIDirectRcv -> ACID SCTDirect SMDRcv CIDirectRcv + JCIGroupSnd -> ACID SCTGroup SMDSnd CIGroupSnd + JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m -instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions +data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} + deriving (Show) data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) -instance ToJSON (CChatItem c) where - toJSON (CChatItem _ ci) = J.toJSON ci - toEncoding (CChatItem _ ci) = J.toEncoding ci - cchatItemId :: CChatItem c -> ChatItemId cchatItemId (CChatItem _ ci) = chatItemId' ci @@ -269,42 +267,25 @@ data Chat c = Chat chatItems :: [CChatItem c], chatStats :: ChatStats } - deriving (Show, Generic) + deriving (Show) -instance ToJSON (Chat c) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - -data AChat = forall c. AChat (SChatType c) (Chat c) +data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat -instance ToJSON AChat where - toJSON (AChat _ c) = J.toJSON c - toEncoding (AChat _ c) = J.toEncoding c - data ChatStats = ChatStats { unreadCount :: Int, minUnreadItemId :: ChatItemId, unreadChat :: Bool } - deriving (Show, Generic) - -instance ToJSON ChatStats where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) -- | type to show a mix of messages from multiple chats -data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) +data AChatItem = forall c d. (ChatTypeI c, MsgDirectionI d) => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem -instance ToJSON AChatItem where - toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item - toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item - data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} - deriving (Generic) aChatItems :: AChat -> [AChatItem] aChatItems (AChat ct Chat {chatInfo, chatItems}) = map aChatItem chatItems @@ -322,10 +303,6 @@ updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} Nothing -> ci -instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - -- This type is not saved to DB, so all JSON encodings are platform-specific data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta { itemId :: ChatItemId, @@ -342,7 +319,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Show, Generic) + deriving (Show) mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = @@ -351,15 +328,11 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item _ -> False in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt} -instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions - data CITimed = CITimed { ttl :: Int, -- seconds deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read } - deriving (Show, Generic) - -instance ToJSON CITimed where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) ttl' :: CITimed -> Int ttl' CITimed {ttl} = ttl @@ -393,11 +366,7 @@ data CIQuote (c :: ChatType) = CIQuote content :: MsgContent, formattedText :: Maybe MarkdownList } - deriving (Show, Generic) - -instance ToJSON (CIQuote c) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction { chatDir :: CIDirection c d, @@ -405,26 +374,15 @@ data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction sentAt :: UTCTime, reaction :: MsgReaction } - deriving (Show, Generic) + deriving (Show) -instance ToJSON (CIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data AnyCIReaction = forall c d. ChatTypeI c => ACIR (SChatType c) (SMsgDirection d) (CIReaction c d) -data ACIReaction = forall c d. ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) +data ACIReaction = forall c d. ChatTypeI c => ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) deriving instance Show ACIReaction -instance ToJSON ACIReaction where - toJSON (ACIReaction _ _ chat reaction) = J.toJSON $ JSONCIReaction chat reaction - toEncoding (ACIReaction _ _ chat reaction) = J.toEncoding $ JSONCIReaction chat reaction - data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} - deriving (Generic) - -instance ToJSON (JSONCIReaction c d) where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect @@ -434,9 +392,7 @@ data CIQDirection (c :: ChatType) where deriving instance Show (CIQDirection c) -instance ToJSON (CIQDirection c) where - toJSON = J.toJSON . jsonCIQDirection - toEncoding = J.toEncoding . jsonCIQDirection +data ACIQDirection = forall c. ChatTypeI c => ACIQDirection (SChatType c) (CIQDirection c) jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection jsonCIQDirection = \case @@ -446,6 +402,14 @@ jsonCIQDirection = \case CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m CIQGroupRcv Nothing -> Nothing +jsonACIQDirection :: Maybe JSONCIDirection -> ACIQDirection +jsonACIQDirection = \case + Just JCIDirectSnd -> ACIQDirection SCTDirect CIQDirectSnd + Just JCIDirectRcv -> ACIQDirection SCTDirect CIQDirectRcv + Just JCIGroupSnd -> ACIQDirection SCTGroup CIQGroupSnd + Just (JCIGroupRcv m) -> ACIQDirection SCTGroup $ CIQGroupRcv (Just m) + Nothing -> ACIQDirection SCTGroup $ CIQGroupRcv Nothing + quoteMsgDirection :: CIQDirection c -> MsgDirection quoteMsgDirection = \case CIQDirectSnd -> MDSnd @@ -461,11 +425,7 @@ data CIFile (d :: MsgDirection) = CIFile fileStatus :: CIFileStatus d, fileProtocol :: FileProtocol } - deriving (Show, Generic) - -instance MsgDirectionI d => ToJSON (CIFile d) where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data FileProtocol = FPSMP | FPXFTP deriving (Eq, Show, Ord) @@ -474,6 +434,9 @@ instance FromField FileProtocol where fromField = fromTextField_ textDecode instance ToField FileProtocol where toField = toField . textEncode +instance FromJSON FileProtocol where + parseJSON = textParseJSON "FileProtocol" + instance ToJSON FileProtocol where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -520,14 +483,6 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True -instance ToJSON (CIFileStatus d) where - toJSON = J.toJSON . jsonCIFileStatus - toEncoding = J.toEncoding . jsonCIFileStatus - -instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode - -instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) deriving instance Show ACIFileStatus @@ -585,11 +540,6 @@ data JSONCIFileStatus | JCIFSRcvCancelled | JCIFSRcvError | JCIFSInvalid {text :: Text} - deriving (Generic) - -instance ToJSON JSONCIFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIFS" jsonCIFileStatus :: CIFileStatus d -> JSONCIFileStatus jsonCIFileStatus = \case @@ -651,16 +601,6 @@ deriving instance Eq (CIStatus d) deriving instance Show (CIStatus d) -instance ToJSON (CIStatus d) where - toJSON = J.toJSON . jsonCIStatus - toEncoding = J.toEncoding . jsonCIStatus - -instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode - -instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - -instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 - data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) deriving instance Show ACIStatus @@ -703,11 +643,7 @@ data JSONCIStatus | JCISRcvNew | JCISRcvRead | JCISInvalid {text :: Text} - deriving (Show, Generic) - -instance ToJSON JSONCIStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS" + deriving (Show) jsonCIStatus :: CIStatus d -> JSONCIStatus jsonCIStatus = \case @@ -720,6 +656,17 @@ jsonCIStatus = \case CISRcvRead -> JCISRcvRead CISInvalid text -> JCISInvalid text +jsonACIStatus :: JSONCIStatus -> ACIStatus +jsonACIStatus = \case + JCISSndNew -> ACIStatus SMDSnd CISSndNew + JCISSndSent sndProgress -> ACIStatus SMDSnd $ CISSndSent sndProgress + JCISSndRcvd msgRcptStatus sndProgress -> ACIStatus SMDSnd $ CISSndRcvd msgRcptStatus sndProgress + JCISSndErrorAuth -> ACIStatus SMDSnd CISSndErrorAuth + JCISSndError e -> ACIStatus SMDSnd $ CISSndError e + JCISRcvNew -> ACIStatus SMDRcv CISRcvNew + JCISRcvRead -> ACIStatus SMDRcv CISRcvRead + JCISInvalid text -> ACIStatus SMDSnd $ CISInvalid text + ciStatusNew :: forall d. MsgDirectionI d => CIStatus d ciStatusNew = case msgDirection @d of SMDSnd -> CISSndNew @@ -748,11 +695,7 @@ membersGroupItemStatus memStatusCounts data SndCIStatusProgress = SSPPartial | SSPComplete - deriving (Eq, Show, Generic) - -instance ToJSON SndCIStatusProgress where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" + deriving (Eq, Show) instance StrEncoding SndCIStatusProgress where strEncode = \case @@ -789,6 +732,8 @@ instance TestEquality SChatType where testEquality SCTContactConnection SCTContactConnection = Just Refl testEquality _ _ = Nothing +data AChatType = forall c. ChatTypeI c => ACT (SChatType c) + class ChatTypeI (c :: ChatType) where chatTypeI :: SChatType c @@ -796,6 +741,29 @@ instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup +instance ChatTypeI 'CTContactRequest where chatTypeI = SCTContactRequest + +instance ChatTypeI 'CTContactConnection where chatTypeI = SCTContactConnection + +toChatType :: SChatType c -> ChatType +toChatType = \case + SCTDirect -> CTDirect + SCTGroup -> CTGroup + SCTContactRequest -> CTContactRequest + SCTContactConnection -> CTContactConnection + +aChatType :: ChatType -> AChatType +aChatType = \case + CTDirect -> ACT SCTDirect + CTGroup -> ACT SCTGroup + CTContactRequest -> ACT SCTContactRequest + CTContactConnection -> ACT SCTContactConnection + +checkChatType :: forall t c c'. (ChatTypeI c, ChatTypeI c') => t c' -> Either String (t c) +checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of + Just Refl -> Right x + Nothing -> Left "bad chat type" + data NewMessage e = NewMessage { chatMsgEvent :: ChatMsgEvent e, msgBody :: MsgBody @@ -850,9 +818,7 @@ data MsgMetaJSON = MsgMetaJSON serverTs :: UTCTime, sndId :: Int64 } - deriving (Eq, Show, FromJSON, Generic) - -instance ToJSON MsgMetaJSON where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) msgMetaToJson :: MsgMeta -> MsgMetaJSON msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId, serverTs), sndMsgId = sndId} = @@ -865,9 +831,6 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId sndId } -msgMetaJson :: MsgMeta -> Text -msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson - data MsgDeliveryStatus (d :: MsgDirection) where MDSRcvAgent :: MsgDeliveryStatus 'MDRcv MDSRcvAcknowledged :: MsgDeliveryStatus 'MDRcv @@ -915,38 +878,33 @@ msgDeliveryStatusT' s = Just Refl -> Just st _ -> Nothing -checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) -checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of - Just Refl -> Right x - Nothing -> Left "bad direction" - data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c - CIBlocked :: Maybe UTCTime -> CIDeleted c + CIBlocked :: Maybe UTCTime -> CIDeleted 'CTGroup CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) -instance ToJSON (CIDeleted d) where - toJSON = J.toJSON . jsonCIDeleted - toEncoding = J.toEncoding . jsonCIDeleted +data ACIDeleted = forall c. ChatTypeI c => ACIDeleted (SChatType c) (CIDeleted c) data JSONCIDeleted - = JCIDDeleted {deletedTs :: Maybe UTCTime} + = JCIDDeleted {deletedTs :: Maybe UTCTime, chatType :: ChatType} | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} - deriving (Show, Generic) + deriving (Show) -instance ToJSON JSONCIDeleted where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCID" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCID" - -jsonCIDeleted :: CIDeleted d -> JSONCIDeleted +jsonCIDeleted :: forall d. ChatTypeI d => CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case - CIDeleted ts -> JCIDDeleted ts + CIDeleted ts -> JCIDDeleted ts (toChatType $ chatTypeI @d) CIBlocked ts -> JCIDBlocked ts CIModerated ts m -> JCIDModerated ts m +jsonACIDeleted :: JSONCIDeleted -> ACIDeleted +jsonACIDeleted = \case + JCIDDeleted ts cType -> case aChatType cType of ACT c -> ACIDeleted c $ CIDeleted ts + JCIDBlocked ts -> ACIDeleted SCTGroup $ CIBlocked ts + JCIDModerated ts m -> ACIDeleted SCTGroup (CIModerated ts m) + itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts @@ -957,9 +915,7 @@ data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } - deriving (Eq, Show, Generic) - -instance ToJSON ChatItemInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, @@ -968,9 +924,7 @@ data ChatItemVersion = ChatItemVersion itemVersionTs :: UTCTime, createdAt :: UTCTime } - deriving (Eq, Show, Generic) - -instance ToJSON ChatItemVersion where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content @@ -989,9 +943,7 @@ data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, memberDeliveryStatus :: CIStatus 'MDSnd } - deriving (Eq, Show, Generic) - -instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CIModeration = CIModeration { moderationId :: Int64, @@ -1000,3 +952,187 @@ data CIModeration = CIModeration moderatedAt :: UTCTime } deriving (Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CT") ''ChatType) + +instance ChatTypeI c => FromJSON (SChatType c) where + parseJSON v = (\(ACT t) -> checkChatType t) . aChatType <$?> J.parseJSON v + +instance ToJSON (SChatType c) where + toJSON = J.toJSON . toChatType + toEncoding = J.toEncoding . toChatType + +$(JQ.deriveJSON defaultJSON ''ChatName) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCID") ''JSONCIDeleted) + +instance ChatTypeI c => FromJSON (CIDeleted c) where + parseJSON v = (\(ACIDeleted _ x) -> checkChatType x) . jsonACIDeleted <$?> J.parseJSON v + +instance ChatTypeI c => ToJSON (CIDeleted c) where + toJSON = J.toJSON . jsonCIDeleted + toEncoding = J.toEncoding . jsonCIDeleted + +$(JQ.deriveJSON defaultJSON ''CITimed) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "SSP") ''SndCIStatusProgress) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIS") ''JSONCIStatus) + +instance MsgDirectionI d => FromJSON (CIStatus d) where + parseJSON v = (\(ACIStatus _ s) -> checkDirection s) . jsonACIStatus <$?> J.parseJSON v + +instance ToJSON (CIStatus d) where + toJSON = J.toJSON . jsonCIStatus + toEncoding = J.toEncoding . jsonCIStatus + +instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode + +instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +$(JQ.deriveJSON defaultJSON ''MemberDeliveryStatus) + +$(JQ.deriveJSON defaultJSON ''ChatItemVersion) + +$(JQ.deriveJSON defaultJSON ''ChatItemInfo) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIMeta c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIMeta) + +instance ChatTypeI c => ToJSON (CIMeta c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIMeta) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIFS") ''JSONCIFileStatus) + +instance MsgDirectionI d => FromJSON (CIFileStatus d) where + parseJSON v = (\(AFS _ s) -> checkDirection s) . aciFileStatusJSON <$?> J.parseJSON v + +instance ToJSON (CIFileStatus d) where + toJSON = J.toJSON . jsonCIFileStatus + toEncoding = J.toEncoding . jsonCIFileStatus + +instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +instance MsgDirectionI d => FromJSON (CIFile d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIFile) + +instance MsgDirectionI d => ToJSON (CIFile d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIFile) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where + parseJSON v = (\(CCID _ x') -> checkDirection x') <$?> J.parseJSON v + +instance ToJSON (CIDirection c d) where + toJSON = J.toJSON . jsonCIDirection + toEncoding = J.toEncoding . jsonCIDirection + +instance ChatTypeI c => FromJSON (CCIDirection c) where + parseJSON v = (\(ACID _ d x) -> checkChatType (CCID d x)) <$?> J.parseJSON v + +instance FromJSON ACIDirection where + parseJSON v = jsonACIDirection <$> J.parseJSON v + +instance ChatTypeI c => FromJSON (CIQDirection c) where + parseJSON v = (\(ACIQDirection _ x) -> checkChatType x) . jsonACIQDirection <$?> J.parseJSON v + +instance ToJSON (CIQDirection c) where + toJSON = J.toJSON . jsonCIQDirection + toEncoding = J.toEncoding . jsonCIQDirection + +instance ChatTypeI c => FromJSON (CIQuote c) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIQuote) + +$(JQ.deriveToJSON defaultJSON ''CIQuote) + +$(JQ.deriveJSON defaultJSON ''CIReactionCount) + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (ChatItem c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (ChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''ChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''ChatItem) + +instance (ChatTypeI c, MsgDirectionI d) => ToJSON (JSONAnyChatItem c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONAnyChatItem) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONAnyChatItem) + +instance FromJSON AChatItem where + parseJSON = J.withObject "AChatItem" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + CChatItem d chatItem <- o .: "chatItem" + pure $ AChatItem c d chatInfo chatItem + +instance ToJSON AChatItem where + toJSON (AChatItem _ _ chat item) = J.toJSON $ JSONAnyChatItem chat item + toEncoding (AChatItem _ _ chat item) = J.toEncoding $ JSONAnyChatItem chat item + +instance forall c. ChatTypeI c => FromJSON (CChatItem c) where + parseJSON v = J.withObject "CChatItem" parse v + where + parse o = do + CCID d (_ :: CIDirection c d) <- o .: "chatDir" + ci <- J.parseJSON @(ChatItem c d) v + pure $ CChatItem d ci + +instance ChatTypeI c => ToJSON (CChatItem c) where + toJSON (CChatItem _ ci) = J.toJSON ci + toEncoding (CChatItem _ ci) = J.toEncoding ci + +$(JQ.deriveJSON defaultJSON ''ChatStats) + +instance ChatTypeI c => ToJSON (Chat c) where + toJSON = $(JQ.mkToJSON defaultJSON ''Chat) + toEncoding = $(JQ.mkToEncoding defaultJSON ''Chat) + +instance FromJSON AChat where + parseJSON = J.withObject "AChat" $ \o -> do + AChatInfo c chatInfo <- o .: "chatInfo" + chatItems <- o .: "chatItems" + chatStats <- o .: "chatStats" + pure $ AChat c Chat {chatInfo, chatItems, chatStats} + +instance ToJSON AChat where + toJSON (AChat _ c) = J.toJSON c + toEncoding (AChat _ c) = J.toEncoding c + +instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIReaction c d) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''CIReaction) + +instance ChatTypeI c => ToJSON (CIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''CIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''CIReaction) + +instance FromJSON AnyCIReaction where + parseJSON v = J.withObject "AnyCIReaction" parse v + where + parse o = do + ACID c d (_ :: CIDirection c d) <- o .: "chatDir" + ACIR c d <$> J.parseJSON @(CIReaction c d) v + +instance ChatTypeI c => ToJSON (JSONCIReaction c d) where + toJSON = $(JQ.mkToJSON defaultJSON ''JSONCIReaction) + toEncoding = $(JQ.mkToEncoding defaultJSON ''JSONCIReaction) + +instance FromJSON ACIReaction where + parseJSON = J.withObject "ACIReaction" $ \o -> do + ACIR c d reaction <- o .: "chatReaction" + cInfo <- o .: "chatInfo" + pure $ ACIReaction c d cInfo reaction + +instance ToJSON ACIReaction where + toJSON (ACIReaction _ _ cInfo reaction) = J.toJSON $ JSONCIReaction cInfo reaction + toEncoding (ACIReaction _ _ cInfo reaction) = J.toEncoding $ JSONCIReaction cInfo reaction + +$(JQ.deriveJSON defaultJSON ''MsgMetaJSON) + +msgMetaJson :: MsgMeta -> Text +msgMetaJson = decodeLatin1 . LB.toStrict . J.encode . msgMetaToJson diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index ea5c7dfe0..8d5e2ddd8 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -24,25 +23,20 @@ import Data.Type.Equality import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) +import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) +import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>)) data MsgDirection = MDRcv | MDSnd - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgDirection where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD" - -instance ToJSON MsgDirection where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MD" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MD" +$(JQ.deriveJSON (enumJSON $ dropPrefix "MD") ''MsgDirection) instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgDirection . msgDirectionIntP @@ -59,6 +53,13 @@ instance TestEquality SMsgDirection where testEquality SMDSnd SMDSnd = Just Refl testEquality _ _ = Nothing +instance MsgDirectionI d => FromJSON (SMsgDirection d) where + parseJSON v = (\(AMsgDirection d) -> checkDirection d) . fromMsgDirection <$?> J.parseJSON v + +instance ToJSON (SMsgDirection d) where + toJSON = J.toJSON . toMsgDirection + toEncoding = J.toEncoding . toMsgDirection + instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection data AMsgDirection = forall d. MsgDirectionI d => AMsgDirection (SMsgDirection d) @@ -82,6 +83,11 @@ instance MsgDirectionI 'MDRcv where msgDirection = SMDRcv instance MsgDirectionI 'MDSnd where msgDirection = SMDSnd +checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d) +checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of + Just Refl -> Right x + Nothing -> Left "bad direction" + msgDirectionInt :: MsgDirection -> Int msgDirectionInt = \case MDRcv -> 0 @@ -94,14 +100,9 @@ msgDirectionIntP = \case _ -> Nothing data CIDeleteMode = CIDMBroadcast | CIDMInternal - deriving (Show, Generic) + deriving (Show) -instance ToJSON CIDeleteMode where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIDM" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIDM" - -instance FromJSON CIDeleteMode where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIDM" +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case @@ -156,14 +157,7 @@ data MsgDecryptError | MDERatchetEarlier | MDEOther | MDERatchetSync - deriving (Eq, Show, Generic) - -instance ToJSON MsgDecryptError where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MDE" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MDE" - -instance FromJSON MsgDecryptError where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MDE" + deriving (Eq, Show) ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool ciRequiresAttention content = case msgDirection @d of @@ -197,127 +191,6 @@ ciRequiresAttention content = case msgDirection @d of CIRcvModerated -> True CIInvalidJSON _ -> False -data RcvGroupEvent - = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting - | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember - | RGEMemberLeft -- CRLeftMember - | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | RGEUserRole {role :: GroupMemberRole} - | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember - | RGEUserDeleted -- CRDeletedMemberUser - | RGEGroupDeleted -- CRGroupDeleted - | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, - -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") - -- and be created as unread without adding / working around new status for sent items - | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink - | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv - deriving (Show, Generic) - -instance FromJSON RcvGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RGE" - -instance ToJSON RcvGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RGE" - -newtype DBRcvGroupEvent = RGE RcvGroupEvent - -instance FromJSON DBRcvGroupEvent where - parseJSON v = RGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RGE") v - -instance ToJSON DBRcvGroupEvent where - toJSON (RGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RGE") v - toEncoding (RGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RGE") v - -data SndGroupEvent - = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember - | SGEUserLeft -- CRLeftMemberUser - | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated - deriving (Show, Generic) - -instance FromJSON SndGroupEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SGE" - -instance ToJSON SndGroupEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SGE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SGE" - -newtype DBSndGroupEvent = SGE SndGroupEvent - -instance FromJSON DBSndGroupEvent where - parseJSON v = SGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SGE") v - -instance ToJSON DBSndGroupEvent where - toJSON (SGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SGE") v - toEncoding (SGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SGE") v - -data RcvConnEvent - = RCESwitchQueue {phase :: SwitchPhase} - | RCERatchetSync {syncStatus :: RatchetSyncState} - | RCEVerificationCodeReset - deriving (Show, Generic) - -data SndConnEvent - = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} - | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} - deriving (Show, Generic) - -instance FromJSON RcvConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RCE" - -instance ToJSON RcvConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" - -newtype DBRcvConnEvent = RCE RcvConnEvent - -instance FromJSON DBRcvConnEvent where - parseJSON v = RCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RCE") v - -instance ToJSON DBRcvConnEvent where - toJSON (RCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RCE") v - toEncoding (RCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RCE") v - -instance FromJSON SndConnEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "SCE" - -instance ToJSON SndConnEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SCE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SCE" - -newtype DBSndConnEvent = SCE SndConnEvent - -instance FromJSON DBSndConnEvent where - parseJSON v = SCE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SCE") v - -instance ToJSON DBSndConnEvent where - toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v - toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v - -data RcvDirectEvent = - -- RDEProfileChanged {...} - RDEContactDeleted - deriving (Show, Generic) - -instance FromJSON RcvDirectEvent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" - -instance ToJSON RcvDirectEvent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" - -newtype DBRcvDirectEvent = RDE RcvDirectEvent - -instance FromJSON DBRcvDirectEvent where - parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v - -instance ToJSON DBRcvDirectEvent where - toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v - toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v - newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where @@ -334,25 +207,14 @@ data CIGroupInvitation = CIGroupInvitation groupProfile :: GroupProfile, status :: CIGroupInvitationStatus } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CIGroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data CIGroupInvitationStatus = CIGISPending | CIGISAccepted | CIGISRejected | CIGISExpired - deriving (Eq, Show, Generic) - -instance FromJSON CIGroupInvitationStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CIGIS" - -instance ToJSON CIGroupInvitationStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CIGIS" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CIGIS" + deriving (Eq, Show) ciContentToText :: CIContent d -> Text ciContentToText = \case @@ -477,27 +339,10 @@ msgDirToModeratedContent_ = \case ciModeratedText :: Text ciModeratedText = "moderated" --- platform independent -instance MsgDirectionI d => ToField (CIContent d) where - toField = toField . encodeJSON . dbJsonCIContent - --- platform specific -instance MsgDirectionI d => ToJSON (CIContent d) where - toJSON = J.toJSON . jsonCIContent - toEncoding = J.toEncoding . jsonCIContent - data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d) deriving instance Show ACIContent --- platform independent -dbParseACIContent :: Text -> Either String ACIContent -dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 - --- platform specific -instance FromJSON ACIContent where - parseJSON = fmap aciContentJSON . J.parseJSON - -- platform specific data JSONCIContent = JCISndMsgContent {msgContent :: MsgContent} @@ -526,14 +371,6 @@ data JSONCIContent | JCISndModerated | JCIRcvModerated | JCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON JSONCIContent where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - -instance ToJSON JSONCIContent where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" jsonCIContent :: forall d. MsgDirectionI d => CIContent d -> JSONCIContent jsonCIContent = \case @@ -622,14 +459,6 @@ data DBJSONCIContent | DBJCISndModerated | DBJCIRcvModerated | DBJCIInvalidJSON {direction :: MsgDirection, json :: Text} - deriving (Generic) - -instance FromJSON DBJSONCIContent where - parseJSON = J.genericParseJSON . singleFieldJSON $ dropPrefix "DBJCI" - -instance ToJSON DBJSONCIContent where - toJSON = J.genericToJSON . singleFieldJSON $ dropPrefix "DBJCI" - toEncoding = J.genericToEncoding . singleFieldJSON $ dropPrefix "DBJCI" dbJsonCIContent :: forall d. MsgDirectionI d => CIContent d -> DBJSONCIContent dbJsonCIContent = \case @@ -699,14 +528,7 @@ data CICallStatus | CISCallProgress | CISCallEnded | CISCallError - deriving (Show, Generic) - -instance FromJSON CICallStatus where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall" - -instance ToJSON CICallStatus where - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall" - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall" + deriving (Show) ciCallInfoText :: CICallStatus -> Int -> Text ciCallInfoText status duration = case status of @@ -718,3 +540,37 @@ ciCallInfoText status duration = case status of CISCallProgress -> "in progress " <> durationText duration CISCallEnded -> "ended " <> durationText duration CISCallError -> "error" + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MDE") ''MsgDecryptError) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus) + +$(JQ.deriveJSON defaultJSON ''CIGroupInvitation) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus) + +-- platform specific +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIContent) + +-- platform independent +$(JQ.deriveJSON (singleFieldJSON $ dropPrefix "DBJCI") ''DBJSONCIContent) + +-- platform independent +instance MsgDirectionI d => ToField (CIContent d) where + toField = toField . encodeJSON . dbJsonCIContent + +-- platform specific +instance MsgDirectionI d => ToJSON (CIContent d) where + toJSON = J.toJSON . jsonCIContent + toEncoding = J.toEncoding . jsonCIContent + +instance MsgDirectionI d => FromJSON (CIContent d) where + parseJSON v = (\(ACIContent _ c) -> checkDirection c) <$?> J.parseJSON v + +-- platform independent +dbParseACIContent :: Text -> Either String ACIContent +dbParseACIContent = fmap aciContentDBJSON . J.eitherDecodeStrict' . encodeUtf8 + +-- platform specific +instance FromJSON ACIContent where + parseJSON = fmap aciContentJSON . J.parseJSON diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs new file mode 100644 index 000000000..42a5add1d --- /dev/null +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Messages.CIContent.Events where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as J +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (RatchetSyncState (..), SwitchPhase (..)) +import Simplex.Messaging.Parsers (dropPrefix, singleFieldJSON, sumTypeJSON) + +data RcvGroupEvent + = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting + | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember + | RGEMemberLeft -- CRLeftMember + | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | RGEUserRole {role :: GroupMemberRole} + | RGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRDeletedMember + | RGEUserDeleted -- CRDeletedMemberUser + | RGEGroupDeleted -- CRGroupDeleted + | RGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + -- RGEInvitedViaGroupLink chat items are not received - they're created when sending group invitations, + -- but being RcvGroupEvent allows them to be assigned to the respective member (and so enable "send direct message") + -- and be created as unread without adding / working around new status for sent items + | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink + | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv + deriving (Show) + +data SndGroupEvent + = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} + | SGEUserRole {role :: GroupMemberRole} + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEUserLeft -- CRLeftMemberUser + | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + deriving (Show) + +data RcvConnEvent + = RCESwitchQueue {phase :: SwitchPhase} + | RCERatchetSync {syncStatus :: RatchetSyncState} + | RCEVerificationCodeReset + deriving (Show) + +data SndConnEvent + = SCESwitchQueue {phase :: SwitchPhase, member :: Maybe GroupMemberRef} + | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} + deriving (Show) + +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show) + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RGE") ''RcvGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvGroupEvent = RGE RcvGroupEvent + +instance FromJSON DBRcvGroupEvent where + parseJSON v = RGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +instance ToJSON DBRcvGroupEvent where + toJSON (RGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + toEncoding (RGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RGE") ''RcvGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SGE") ''SndGroupEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndGroupEvent = SGE SndGroupEvent + +instance FromJSON DBSndGroupEvent where + parseJSON v = SGE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +instance ToJSON DBSndGroupEvent where + toJSON (SGE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + toEncoding (SGE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SGE") ''SndGroupEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RCE") ''RcvConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvConnEvent = RCE RcvConnEvent + +instance FromJSON DBRcvConnEvent where + parseJSON v = RCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +instance ToJSON DBRcvConnEvent where + toJSON (RCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + toEncoding (RCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RCE") ''RcvConnEvent) v + +-- platform-specific JSON encoding (used in API) +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SCE") ''SndConnEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBSndConnEvent = SCE SndConnEvent + +instance FromJSON DBSndConnEvent where + parseJSON v = SCE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +instance ToJSON DBSndConnEvent where + toJSON (SCE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + toEncoding (SCE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "SCE") ''SndConnEvent) v + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RDE") ''RcvDirectEvent) + +-- platform-independent JSON encoding (stored in DB) +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> $(J.mkParseJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = $(J.mkToJSON (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v + toEncoding (RDE v) = $(J.mkToEncoding (singleFieldJSON $ dropPrefix "RDE") ''RcvDirectEvent) v diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs new file mode 100644 index 000000000..e716b2aa6 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231114_remote_control.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231114_remote_control where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231114_remote_control :: Query +m20231114_remote_control = + [sql| +CREATE TABLE remote_hosts ( -- e.g., mobiles known to a desktop app + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + id_key BLOB NOT NULL, -- long-term/identity signing key + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key +); + +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts(host_fingerprint); + +CREATE TABLE remote_controllers ( -- e.g., desktops known to a mobile app + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key +); + +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers(ctrl_fingerprint); +|] + +down_m20231114_remote_control :: Query +down_m20231114_remote_control = + [sql| +DROP INDEX idx_remote_hosts_host_fingerprint; +DROP INDEX idx_remote_controllers_ctrl_fingerprint; +DROP TABLE remote_hosts; +DROP TABLE remote_controllers; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 6f576a75e..bc441ec6f 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -527,6 +527,28 @@ CREATE TABLE IF NOT EXISTS "received_probes"( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL) ); +CREATE TABLE remote_hosts( + -- e.g., mobiles known to a desktop app + remote_host_id INTEGER PRIMARY KEY AUTOINCREMENT, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, -- relative folder name for host files + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + id_key BLOB NOT NULL, -- long-term/identity signing key + host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected + host_dh_pub BLOB NOT NULL -- last session DH key +); +CREATE TABLE remote_controllers( + -- e.g., desktops known to a mobile app + remote_ctrl_id INTEGER PRIMARY KEY AUTOINCREMENT, + ctrl_device_name TEXT NOT NULL, + ca_key BLOB NOT NULL, + ca_cert BLOB NOT NULL, + ctrl_fingerprint BLOB NOT NULL, -- remote controller CA cert fingerprint, set when connected + id_pub BLOB NOT NULL, -- remote controller long-term/identity key to verify signatures + dh_priv_key BLOB NOT NULL, -- last session DH key + prev_dh_priv_key BLOB -- previous session DH key +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -778,3 +800,9 @@ CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( forwarded_by_group_member_id ); +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( + host_fingerprint +); +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( + ctrl_fingerprint +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8888ed13e..ffcf5a0ce 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,18 +1,20 @@ -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fobject-code #-} + module Simplex.Chat.Mobile where import Control.Concurrent.STM import Control.Exception (catch, SomeException) import Control.Monad.Except import Control.Monad.Reader -import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) @@ -30,7 +32,6 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) -import GHC.Generics (Generic) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) @@ -38,6 +39,7 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options +import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types @@ -47,18 +49,34 @@ import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), Migrati import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), BasicAuth (..), CorrId (..), ProtoServerWithAuth (..), ProtocolServer (..)) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +data DBMigrationResult + = DBMOk + | DBMInvalidConfirmation + | DBMErrorNotADatabase {dbFile :: String} + | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} + | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} + deriving (Show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "DBM") ''DBMigrationResult) + +data APIResponse = APIResponse {corr :: Maybe CorrId, remoteHostId :: Maybe RemoteHostId, resp :: ChatResponse} + +$(JQ.deriveToJSON defaultJSON ''APIResponse) + foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString +foreign export ccall "chat_send_remote_cmd" cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString + foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString @@ -111,6 +129,14 @@ cChatSendCmd cPtr cCmd = do cmd <- B.packCString cCmd newCStringFromLazyBS =<< chatSendCmd c cmd +-- | send command to chat (same syntax as in terminal for now) +cChatSendRemoteCmd :: StablePtr ChatController -> CInt -> CString -> IO CJSONString +cChatSendRemoteCmd cPtr cRemoteHostId cCmd = do + c <- deRefStablePtr cPtr + cmd <- B.packCString cCmd + let rhId = Just $ fromIntegral cRemoteHostId + newCStringFromLazyBS =<< chatSendRemoteCmd c rhId cmd + -- | receive message from chat (blocking) cChatRecvMsg :: StablePtr ChatController -> IO CJSONString cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCStringFromLazyBS @@ -177,18 +203,6 @@ defaultMobileConfig = getActiveUser_ :: SQLiteStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers -data DBMigrationResult - = DBMOk - | DBMInvalidConfirmation - | DBMErrorNotADatabase {dbFile :: String} - | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} - | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} - deriving (Show, Generic) - -instance ToJSON DBMigrationResult where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DBM" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DBM" - chatMigrateInit :: String -> String -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm @@ -218,13 +232,16 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) -chatSendCmd :: ChatController -> ByteString -> IO JSONByteString -chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc +chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString +chatSendCmd cc = chatSendRemoteCmd cc Nothing + +chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO JSONByteString +chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where - json (corr, resp) = J.encode APIResponse {corr, resp} + json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) @@ -249,10 +266,3 @@ chatPasswordHash pwd salt = either (const "") passwordHash salt' where salt' = U.decode salt passwordHash = U.encode . C.sha512Hash . (pwd <>) - -data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} - deriving (Generic) - -instance ToJSON APIResponse where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index f385e14f4..284f56902 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -1,7 +1,7 @@ {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} module Simplex.Chat.Mobile.File @@ -17,8 +17,9 @@ module Simplex.Chat.Mobile.File where import Control.Monad.Except -import Data.Aeson (ToJSON) +import Control.Monad.IO.Class import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB @@ -30,7 +31,6 @@ import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr import Foreign.Storable (poke, pokeByteOff) -import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..), CryptoFileHandle, FTCryptoError (..)) @@ -43,9 +43,8 @@ import UnliftIO (Handle, IOMode (..), withFile) data WriteFileResult = WFResult {cryptoArgs :: CryptoFileArgs} | WFError {writeError :: String} - deriving (Generic) -instance ToJSON WriteFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "WF" +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) cChatWriteFile :: CString -> Ptr Word8 -> CInt -> IO CJSONString cChatWriteFile cPath ptr len = do @@ -64,9 +63,6 @@ chatWriteFile path s = do data ReadFileResult = RFResult {fileSize :: Int} | RFError {readError :: String} - deriving (Generic) - -instance ToJSON ReadFileResult where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RF" cChatReadFile :: CString -> CString -> CString -> IO (Ptr Word8) cChatReadFile cPath cKey cNonce = do @@ -102,7 +98,7 @@ chatEncryptFile fromPath toPath = either WFError WFResult <$> runCatchExceptT encrypt where encrypt = do - cfArgs <- liftIO $ CF.randomArgs + cfArgs <- liftIO CF.randomArgs encryptFile fromPath toPath cfArgs pure cfArgs @@ -139,3 +135,5 @@ chatDecryptFile fromPath keyStr nonceStr toPath = fromLeft "" <$> runCatchExcept runCatchExceptT :: ExceptT String IO a -> IO (Either String a) runCatchExceptT action = runExceptT action `catchAll` (pure . Left . show) + +$(JQ.deriveToJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 86069d779..2040941aa 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} @@ -12,6 +11,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Protocol where @@ -22,6 +22,7 @@ import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -39,13 +40,12 @@ import Data.Typeable (Typeable) import Data.Word (Word32) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) @@ -77,11 +77,9 @@ data ConnectionEntity | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance ToJSON ConnectionEntity where - toJSON = J.genericToJSON $ sumTypeJSON fstToLower - toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''ConnectionEntity) updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of @@ -108,8 +106,6 @@ instance MsgEncodingI 'Binary where encoding = SBinary instance MsgEncodingI 'Json where encoding = SJson -data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) - instance TestEquality SMsgEncoding where testEquality SBinary SBinary = Just Refl testEquality SJson SJson = Just Refl @@ -131,7 +127,6 @@ data AppMessageJson = AppMessageJson event :: Text, params :: J.Object } - deriving (Eq, Show, Generic, FromJSON) data AppMessageBinary = AppMessageBinary { msgId :: Maybe SharedMsgId, @@ -139,10 +134,6 @@ data AppMessageBinary = AppMessageBinary body :: ByteString } -instance ToJSON AppMessageJson where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - instance StrEncoding AppMessageBinary where strEncode AppMessageBinary {tag, msgId, body} = smpEncode (tag, msgId', Tail body) where @@ -171,20 +162,42 @@ instance ToJSON SharedMsgId where toJSON = strToJSON toEncoding = strToJEncoding +$(JQ.deriveJSON defaultJSON ''AppMessageJson) + data MsgRef = MsgRef { msgId :: Maybe SharedMsgId, sentAt :: UTCTime, sent :: Bool, memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received } - deriving (Eq, Show, Generic) + deriving (Eq, Show) -instance FromJSON MsgRef where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} +$(JQ.deriveJSON defaultJSON ''MsgRef) -instance ToJSON MsgRef where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} + deriving (Eq, Show) + +data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +$(pure []) + +instance FromJSON LinkContent where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + <|> LCUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) + +instance ToJSON LinkContent where + toJSON = \case + LCUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + toEncoding = \case + LCUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "LC") ''LinkContent) v + +$(JQ.deriveJSON defaultJSON ''LinkPreview) data ChatMessage e = ChatMessage { chatVRange :: VersionRange, @@ -195,19 +208,6 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) -instance MsgEncodingI e => StrEncoding (ChatMessage e) where - strEncode msg = case chatToAppMessage msg of - AMJson m -> LB.toStrict $ J.encode m - AMBinary m -> strEncode m - strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP - -instance StrEncoding AChatMessage where - strEncode (ACMsg _ m) = strEncode m - strP = - A.peekChar' >>= \case - '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) - _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) - data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -359,11 +359,7 @@ instance Encoding InlineFileChunk where pure FileChunk {chunkNo = fromIntegral $ c2w c, chunkBytes} data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON QuotedMsg where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions + deriving (Eq, Show) cmToQuotedMsg :: AChatMsgEvent -> Maybe QuotedMsg cmToQuotedMsg = \case @@ -416,34 +412,6 @@ isQuote = \case MCQuote {} -> True _ -> False -data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} - deriving (Eq, Show, Generic) - -data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} - deriving (Eq, Show, Generic) - -instance FromJSON LinkPreview where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} - -instance ToJSON LinkPreview where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON LinkContent where - parseJSON v@(J.Object j) = - J.genericParseJSON (taggedObjectJSON $ dropPrefix "LC") v - <|> LCUnknown <$> j .: "type" <*> pure j - parseJSON invalid = - JT.prependFailure "bad LinkContent, " (JT.typeMismatch "Object" invalid) - -instance ToJSON LinkContent where - toJSON = \case - LCUnknown _ j -> J.Object j - v -> J.genericToJSON (taggedObjectJSON $ dropPrefix "LC") v - toEncoding = \case - LCUnknown _ j -> JE.value $ J.Object j - v -> J.genericToEncoding (taggedObjectJSON $ dropPrefix "LC") v - data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -496,6 +464,21 @@ msgContentTag = \case data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} deriving (Eq, Show) +$(JQ.deriveJSON defaultJSON ''QuotedMsg) + +instance MsgEncodingI e => StrEncoding (ChatMessage e) where + strEncode msg = case chatToAppMessage msg of + AMJson m -> LB.toStrict $ J.encode m + AMBinary m -> strEncode m + strP = (\(ACMsg _ m) -> checkEncoding m) <$?> strP + +instance StrEncoding AChatMessage where + strEncode (ACMsg _ m) = strEncode m + strP = + A.peekChar' >>= \case + '{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString) + _ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP) + parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc @@ -575,6 +558,8 @@ instance ToField MsgContent where instance FromField MsgContent where fromField = fromTextField_ decodeJSON +data ACMEventTag = forall e. MsgEncodingI e => ACMEventTag (SMsgEncoding e) (CMEventTag e) + data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json XMsgFileDescr_ :: CMEventTag 'Json diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs new file mode 100644 index 000000000..d9ef5bd64 --- /dev/null +++ b/src/Simplex/Chat/Remote.hs @@ -0,0 +1,696 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Remote where + +import Control.Applicative ((<|>)) +import Control.Logger.Simple +import Control.Monad +import Control.Monad.Except +import Control.Monad.IO.Class +import Control.Monad.Reader +import Crypto.Random (getRandomBytes) +import qualified Data.Aeson as J +import qualified Data.Aeson.Types as JT +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64.URL as B64U +import Data.ByteString.Builder (Builder) +import qualified Data.ByteString.Char8 as B +import Data.Functor (($>)) +import Data.List.NonEmpty (nonEmpty) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Word (Word32) +import qualified Network.HTTP.Types as N +import Network.HTTP2.Server (responseStreaming) +import qualified Paths_simplex_chat as SC +import Simplex.Chat.Archive (archiveFilesFolder) +import Simplex.Chat.Controller +import Simplex.Chat.Files +import Simplex.Chat.Messages (chatNameStr) +import Simplex.Chat.Remote.AppVersion +import Simplex.Chat.Remote.Protocol +import Simplex.Chat.Remote.RevHTTP (attachHTTP2Server, attachRevHTTP2Client) +import Simplex.Chat.Remote.Transport +import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Files +import Simplex.Chat.Store.Remote +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types +import Simplex.Chat.Util (encryptFile) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Agent +import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP)) +import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import qualified Simplex.Messaging.Crypto.File as CF +import Simplex.Messaging.Encoding.String (StrEncoding (..)) +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (TLS, closeConnection, tlsUniq) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError, closeHTTP2Client) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..)) +import Simplex.Messaging.Util +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..), RCVerifiedInvitation (..), verifySignedInvitation) +import Simplex.RemoteControl.Types +import System.FilePath (takeFileName, ()) +import UnliftIO +import UnliftIO.Concurrent (forkIO) +import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) + +-- when acting as host +minRemoteCtrlVersion :: AppVersion +minRemoteCtrlVersion = AppVersion [5, 4, 0, 3] + +-- when acting as controller +minRemoteHostVersion :: AppVersion +minRemoteHostVersion = AppVersion [5, 4, 0, 3] + +currentAppVersion :: AppVersion +currentAppVersion = AppVersion SC.version + +ctrlAppVersionRange :: AppVersionRange +ctrlAppVersionRange = mkAppVersionRange minRemoteHostVersion currentAppVersion + +hostAppVersionRange :: AppVersionRange +hostAppVersionRange = mkAppVersionRange minRemoteCtrlVersion currentAppVersion + +networkIOTimeout :: Int +networkIOTimeout = 15000000 + +discoveryTimeout :: Int +discoveryTimeout = 60000000 + +-- * Desktop side + +getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient +getRemoteHostClient rhId = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ TM.lookup rhKey sessions >>= \case + Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing + where + rhKey = RHId rhId + +withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a +withRemoteHostSession rhKey sseq f = do + sessions <- asks remoteHostSessions + r <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing + Just (stateSeq, state) + | stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState + | otherwise -> case f state of + Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions + Left ce -> pure $ Left ce + liftEither r + +-- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId' +setNewRemoteHostId :: ChatMonad m => SessionSeq -> RemoteHostId -> m () +setNewRemoteHostId sseq rhId = do + sessions <- asks remoteHostSessions + liftIOEither . atomically $ do + TM.lookup RHNew sessions >>= \case + Nothing -> err RHEMissing + Just sess@(stateSeq, _) + | stateSeq /= sseq -> err RHEBadState + | otherwise -> do + TM.delete RHNew sessions + TM.insert (RHId rhId) sess sessions + pure $ Right () + where + err = pure . Left . ChatErrorRemoteHost RHNew + +startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost rh_ = do + (rhKey, multicast, remoteHost_, pairing) <- case rh_ of + Just (rhId, multicast) -> do + rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId + pure (RHId rhId, multicast, Just $ remoteHostInfo rh $ Just RHSStarting, hostPairing) -- get from the database, start multicast if requested + Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing + sseq <- startRemoteHostSession rhKey + ctrlAppInfo <- mkCtrlAppInfo + (invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast + cmdOk <- newEmptyTMVarIO + rhsWaitSession <- async $ do + rhKeyVar <- newTVarIO rhKey + atomically $ takeTMVar cmdOk + handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars + let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_} + withRemoteHostSession rhKey sseq $ \case + RHSessionStarting -> + let inv = decodeLatin1 $ strEncode invitation + in Right ((), RHSessionConnecting inv rhs) + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState + (remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) + where + mkCtrlAppInfo = do + deviceName <- chatReadVar localDeviceName + pure CtrlAppInfo {appVersionRange = ctrlAppVersionRange, deviceName} + parseHostAppInfo :: RCHostHello -> ExceptT RemoteHostError IO HostAppInfo + parseHostAppInfo RCHostHello {app = hostAppInfo} = do + hostInfo@HostAppInfo {appVersion, encoding} <- + liftEitherWith (RHEProtocolError . RPEInvalidJSON) $ JT.parseEither J.parseJSON hostAppInfo + unless (isAppCompatible appVersion ctrlAppVersionRange) $ throwError $ RHEBadVersion appVersion + when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding + pure hostInfo + handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a + handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do + logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err + cancelRemoteHostSession (Just sessSeq) rhKey + throwError err + handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () + handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just sessSeq) + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do + (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars + let sessionCode = verificationCode sessId + withRemoteHostSession rhKey sseq $ \case + RHSessionConnecting _inv rhs' -> Right ((), RHSessionPendingConfirmation sessionCode tls rhs') + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState + let rh_' = (\rh -> (rh :: RemoteHostInfo) {sessionState = Just RHSPendingConfirmation {sessionCode}}) <$> remoteHost_ + toView $ CRRemoteHostSessionCode {remoteHost_ = rh_', sessionCode} + (RCHostSession {sessionKeys}, rhHello, pairing') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars' + hostInfo@HostAppInfo {deviceName = hostDeviceName} <- + liftError (ChatErrorRemoteHost rhKey) $ parseHostAppInfo rhHello + withRemoteHostSession rhKey sseq $ \case + RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName sseq RHSConfirmed {sessionCode} + let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew + when (rhKey' /= rhKey) $ do + atomically $ writeTVar rhKeyVar rhKey' + toView $ CRNewRemoteHost rhi + -- set up HTTP transport and remote profile protocol + disconnected <- toIO $ onDisconnected rhKey' sseq + httpClient <- liftEitherError (httpError remoteHostId) $ attachRevHTTP2Client disconnected tls + rhClient <- mkRemoteHostClient httpClient sessionKeys sessId storePath hostInfo + pollAction <- async $ pollEvents remoteHostId rhClient + withRemoteHostSession rhKey' sseq $ \case + RHSessionConfirmed _ RHPendingSession {rchClient} -> Right ((), RHSessionConnected {rchClient, tls, rhClient, pollAction, storePath}) + _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState + chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host + toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do + KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_ + case rhi_ of + Nothing -> do + storePath <- liftIO randomStorePath + rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db + setNewRemoteHostId sseq remoteHostId + pure $ remoteHostInfo rh $ Just state + Just rhi@RemoteHostInfo {remoteHostId} -> do + withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' + pure (rhi :: RemoteHostInfo) {sessionState = Just state} + onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () + onDisconnected rhKey sseq = do + logDebug $ "HTTP2 client disconnected: " <> tshow (rhKey, sseq) + cancelRemoteHostSession (Just sseq) rhKey + pollEvents :: ChatMonad m => RemoteHostId -> RemoteHostClient -> m () + pollEvents rhId rhClient = do + oq <- asks outputQ + forever $ do + r_ <- liftRH rhId $ remoteRecv rhClient 10000000 + forM r_ $ \r -> atomically $ writeTBQueue oq (Nothing, Just rhId, r) + httpError :: RemoteHostId -> HTTP2ClientError -> ChatError + httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow + +startRemoteHostSession :: ChatMonad m => RHKey -> m SessionSeq +startRemoteHostSession rhKey = do + sessions <- asks remoteHostSessions + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + TM.lookup rhKey sessions >>= \case + Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBusy + Nothing -> do + sessionSeq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sessionSeq <$ TM.insert rhKey (sessionSeq, RHSessionStarting) sessions + +closeRemoteHost :: ChatMonad m => RHKey -> m () +closeRemoteHost rhKey = do + logNote $ "Closing remote host session for " <> tshow rhKey + cancelRemoteHostSession Nothing rhKey + +cancelRemoteHostSession :: ChatMonad m => Maybe SessionSeq -> RHKey -> m () +cancelRemoteHostSession sseq_ rhKey = do + sessions <- asks remoteHostSessions + crh <- asks currentRemoteHost + deregistered <- atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure Nothing + Just (sessSeq, _) | maybe False (/= sessSeq) sseq_ -> pure Nothing -- ignore cancel from a ghost session handler + Just (_, rhs) -> do + TM.delete rhKey sessions + modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH + pure $ Just rhs + forM_ deregistered $ \session -> do + liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) + when handlingError $ toView $ CRRemoteHostStopped rhId_ + where + handlingError = isJust sseq_ + rhId_ = case rhKey of + RHNew -> Nothing + RHId rhId -> Just rhId + +cancelRemoteHost :: Bool -> RemoteHostSession -> IO () +cancelRemoteHost handlingError = \case + RHSessionStarting -> pure () + RHSessionConnecting _inv rhs -> cancelPendingSession rhs + RHSessionPendingConfirmation _sessCode tls rhs -> do + cancelPendingSession rhs + closeConnection tls + RHSessionConfirmed tls rhs -> do + cancelPendingSession rhs + closeConnection tls + RHSessionConnected {rchClient, tls, rhClient = RemoteHostClient {httpClient}, pollAction} -> do + uninterruptibleCancel pollAction + cancelHostClient rchClient `catchAny` (logError . tshow) + closeConnection tls `catchAny` (logError . tshow) + unless handlingError $ closeHTTP2Client httpClient `catchAny` (logError . tshow) + where + cancelPendingSession RHPendingSession {rchClient, rhsWaitSession} = do + unless handlingError $ uninterruptibleCancel rhsWaitSession `catchAny` (logError . tshow) + cancelHostClient rchClient `catchAny` (logError . tshow) + +-- | Generate a random 16-char filepath without / in it by using base64url encoding. +randomStorePath :: IO FilePath +randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12 + +listRemoteHosts :: ChatMonad m => m [RemoteHostInfo] +listRemoteHosts = do + sessions <- chatReadVar remoteHostSessions + map (rhInfo sessions) <$> withStore' getRemoteHosts + where + rhInfo sessions rh@RemoteHost {remoteHostId} = + remoteHostInfo rh $ rhsSessionState . snd <$> M.lookup (RHId remoteHostId) sessions + +switchRemoteHost :: ChatMonad m => Maybe RemoteHostId -> m (Maybe RemoteHostInfo) +switchRemoteHost rhId_ = do + rhi_ <- forM rhId_ $ \rhId -> do + let rhKey = RHId rhId + rh <- withStore (`getRemoteHost` rhId) + sessions <- chatReadVar remoteHostSessions + case M.lookup rhKey sessions of + Just (_, RHSessionConnected {tls}) -> pure $ remoteHostInfo rh $ Just RHSConnected {sessionCode = tlsSessionCode tls} + _ -> throwError $ ChatErrorRemoteHost rhKey RHEInactive + rhi_ <$ chatWriteVar currentRemoteHost rhId_ + +remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} + +deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () +deleteRemoteHost rhId = do + RemoteHost {storePath} <- withStore (`getRemoteHost` rhId) + chatReadVar remoteHostsFolder >>= \case + Just baseDir -> do + let hostStore = baseDir storePath + logInfo $ "removing host store at " <> tshow hostStore + whenM (doesDirectoryExist hostStore) $ removeDirectoryRecursive hostStore + Nothing -> logWarn "Local file store not available while deleting remote host" + withStore' (`deleteRemoteHostRecord` rhId) + +storeRemoteFile :: forall m. ChatMonad m => RemoteHostId -> Maybe Bool -> FilePath -> m CryptoFile +storeRemoteFile rhId encrypted_ localPath = do + c@RemoteHostClient {encryptHostFiles, storePath} <- getRemoteHostClient rhId + let encrypt = fromMaybe encryptHostFiles encrypted_ + cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath + filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) + hf_ <- chatReadVar remoteHostsFolder + forM_ hf_ $ \hf -> do + let rhf = hf storePath archiveFilesFolder + hPath = rhf takeFileName filePath' + createDirectoryIfMissing True rhf + (if encrypt then renameFile else copyFile) filePath hPath + pure (cf :: CryptoFile) {filePath = filePath'} + where + encryptLocalFile :: m CryptoFile + encryptLocalFile = do + tmpDir <- getChatTempDirectory + createDirectoryIfMissing True tmpDir + tmpFile <- tmpDir `uniqueCombine` takeFileName localPath + cfArgs <- liftIO CF.randomArgs + liftError (ChatError . CEFileWrite tmpFile) $ encryptFile localPath tmpFile cfArgs + pure $ CryptoFile tmpFile $ Just cfArgs + +getRemoteFile :: ChatMonad m => RemoteHostId -> RemoteFile -> m () +getRemoteFile rhId rf = do + c@RemoteHostClient {storePath} <- getRemoteHostClient rhId + dir <- ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder) + createDirectoryIfMissing True dir + liftRH rhId $ remoteGetFile c dir rf + +processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostClient -> ChatCommand -> ByteString -> m ChatResponse +processRemoteCommand remoteHostId c cmd s = case cmd of + SendFile chatName f -> sendFile "/f" chatName f + SendImage chatName f -> sendFile "/img" chatName f + _ -> liftRH remoteHostId $ remoteSend c s + where + sendFile cmdName chatName (CryptoFile path cfArgs) = do + -- don't encrypt in host if already encrypted locally + CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path + let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption + liftRH remoteHostId $ remoteSend c $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f] + cryptoFileStr CryptoFile {filePath, cryptoArgs} = + maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs + <> encodeUtf8 (T.pack filePath) + +liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a +liftRH rhId = liftError (ChatErrorRemoteHost (RHId rhId) . RHEProtocolError) + +-- * Mobile side + +-- ** QR/link + +-- | Use provided OOB link as an annouce +connectRemoteCtrlURI :: ChatMonad m => RCSignedInvitation -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrlURI signedInv = do + verifiedInv <- maybe (throwError $ ChatErrorRemoteCtrl RCEBadInvitation) pure $ verifySignedInvitation signedInv + sseq <- startRemoteCtrlSession + connectRemoteCtrl verifiedInv sseq + +-- ** Multicast + +findKnownRemoteCtrl :: ChatMonad m => m () +findKnownRemoteCtrl = do + knownCtrls <- withStore' getRemoteCtrls + pairings <- case nonEmpty knownCtrls of + Nothing -> throwError $ ChatErrorRemoteCtrl RCENoKnownControllers + Just ne -> pure $ fmap (\RemoteCtrl {ctrlPairing} -> ctrlPairing) ne + sseq <- startRemoteCtrlSession + foundCtrl <- newEmptyTMVarIO + cmdOk <- newEmptyTMVarIO + action <- async $ handleCtrlError sseq "findKnownRemoteCtrl.discover" $ do + atomically $ takeTMVar cmdOk + (RCCtrlPairing {ctrlFingerprint}, inv) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings + rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case + Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" + Just rc -> pure rc + atomically $ putTMVar foundCtrl (rc, inv) + toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching)} + updateRemoteCtrlSession sseq $ \case + RCSessionStarting -> Right RCSessionSearching {action, foundCtrl} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + atomically $ putTMVar cmdOk () + +confirmRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m (RemoteCtrlInfo, CtrlAppInfo) +confirmRemoteCtrl rcId = do + session <- asks remoteCtrlSession + (sseq, listener, found) <- liftIOEither $ atomically $ do + readTVar session >>= \case + Just (sseq, RCSessionSearching {action, foundCtrl}) -> do + writeTVar session $ Just (sseq, RCSessionStarting) -- drop intermediate "Searching" state so connectRemoteCtrl can proceed + pure $ Right (sseq, action, foundCtrl) + _ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState + uninterruptibleCancel listener + (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found + unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController + connectRemoteCtrl verifiedInv sseq >>= \case + (Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" + (Just rci, appInfo) -> pure (rci, appInfo) + +-- ** Common + +startRemoteCtrlSession :: ChatMonad m => m SessionSeq +startRemoteCtrlSession = do + session <- asks remoteCtrlSession + nextSessionSeq <- asks remoteSessionSeq + liftIOEither . atomically $ + readTVar session >>= \case + Just _ -> pure . Left $ ChatErrorRemoteCtrl RCEBusy + Nothing -> do + sseq <- stateTVar nextSessionSeq $ \s -> (s, s + 1) + Right sseq <$ writeTVar session (Just (sseq, RCSessionStarting)) + +connectRemoteCtrl :: ChatMonad m => RCVerifiedInvitation -> SessionSeq -> m (Maybe RemoteCtrlInfo, CtrlAppInfo) +connectRemoteCtrl verifiedInv@(RCVerifiedInvitation inv@RCInvitation {ca, app}) sseq = handleCtrlError sseq "connectRemoteCtrl" $ do + (ctrlInfo@CtrlAppInfo {deviceName = ctrlDeviceName}, v) <- parseCtrlAppInfo app + rc_ <- withStore' $ \db -> getRemoteCtrlByFingerprint db ca + mapM_ (validateRemoteCtrl inv) rc_ + hostAppInfo <- getHostAppInfo v + (rcsClient, vars) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . withAgent $ \a -> + rcConnectCtrl a verifiedInv (ctrlPairing <$> rc_) (J.toJSON hostAppInfo) + cmdOk <- newEmptyTMVarIO + rcsWaitSession <- async $ do + atomically $ takeTMVar cmdOk + handleCtrlError sseq "waitForCtrlSession" $ waitForCtrlSession rc_ ctrlDeviceName rcsClient vars + updateRemoteCtrlSession sseq $ \case + RCSessionStarting -> Right RCSessionConnecting {remoteCtrlId_ = remoteCtrlId' <$> rc_, rcsClient, rcsWaitSession} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + atomically $ putTMVar cmdOk () + pure ((`remoteCtrlInfo` Just RCSConnecting) <$> rc_, ctrlInfo) + where + validateRemoteCtrl RCInvitation {idkey} RemoteCtrl {ctrlPairing = RCCtrlPairing {idPubKey}} = + unless (idkey == idPubKey) $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError $ PRERemoteControl RCEIdentity + waitForCtrlSession :: ChatMonad m => Maybe RemoteCtrl -> Text -> RCCtrlClient -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCCtrlSession, RCCtrlPairing)) -> m () + waitForCtrlSession rc_ ctrlName rcsClient vars = do + (uniq, tls, rcsWaitConfirmation) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars + let sessionCode = verificationCode uniq + updateRemoteCtrlSession sseq $ \case + RCSessionConnecting {rcsWaitSession} -> + let remoteCtrlId_ = remoteCtrlId' <$> rc_ + in Right RCSessionPendingConfirmation {remoteCtrlId_, ctrlDeviceName = ctrlName, rcsClient, tls, sessionCode, rcsWaitSession, rcsWaitConfirmation} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + toView CRRemoteCtrlSessionCode {remoteCtrl_ = (`remoteCtrlInfo` Just RCSPendingConfirmation {sessionCode}) <$> rc_, sessionCode} + parseCtrlAppInfo ctrlAppInfo = do + ctrlInfo@CtrlAppInfo {appVersionRange} <- + liftEitherWith (const $ ChatErrorRemoteCtrl RCEBadInvitation) $ JT.parseEither J.parseJSON ctrlAppInfo + v <- case compatibleAppVersion hostAppVersionRange appVersionRange of + Just (AppCompatible v) -> pure v + Nothing -> throwError $ ChatErrorRemoteCtrl $ RCEBadVersion $ maxVersion appVersionRange + pure (ctrlInfo, v) + getHostAppInfo appVersion = do + hostDeviceName <- chatReadVar localDeviceName + encryptFiles <- chatReadVar encryptLocalFiles + pure HostAppInfo {appVersion, deviceName = hostDeviceName, encoding = localEncoding, encryptFiles} + +handleRemoteCommand :: forall m. ChatMonad m => (ByteString -> m ChatResponse) -> RemoteCrypto -> TBQueue ChatResponse -> HTTP2Request -> m () +handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do + logDebug "handleRemoteCommand" + liftRC (tryRemoteError parseRequest) >>= \case + Right (getNext, rc) -> do + chatReadVar currentUser >>= \case + Nothing -> replyError $ ChatError CENoActiveUser + Just user -> processCommand user getNext rc `catchChatError` replyError + Left e -> reply $ RRProtocolError e + where + parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand) + parseRequest = do + (header, getNext) <- parseDecryptHTTP2Body encryption request reqBody + (getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header) + replyError = reply . RRChatResponse . CRChatCmdError Nothing + processCommand :: User -> GetChunk -> RemoteCommand -> m () + processCommand user getNext = \case + RCSend {command} -> handleSend execChatCommand command >>= reply + RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply + RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile encryption fileName fileSize fileDigest getNext >>= reply + RCGetFile {file} -> handleGetFile encryption user file replyWith + reply :: RemoteResponse -> m () + reply = (`replyWith` \_ -> pure ()) + replyWith :: Respond m + replyWith rr attach = do + resp <- liftRC $ encryptEncodeHTTP2Body encryption $ J.encode rr + liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do + send resp + attach send + flush + +takeRCStep :: ChatMonad m => RCStepTMVar a -> m a +takeRCStep = liftEitherError (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar + +type GetChunk = Int -> IO ByteString + +type SendChunk = Builder -> IO () + +type Respond m = RemoteResponse -> (SendChunk -> IO ()) -> m () + +liftRC :: ChatMonad m => ExceptT RemoteProtocolError IO a -> m a +liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) + +tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolError IO (Either RemoteProtocolError a) +tryRemoteError = tryAllErrors (RPEException . tshow) +{-# INLINE tryRemoteError #-} + +handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse +handleSend execChatCommand command = do + logDebug $ "Send: " <> tshow command + -- execChatCommand checks for remote-allowed commands + -- convert errors thrown in ChatMonad into error responses to prevent aborting the protocol wrapper + RRChatResponse <$> execChatCommand (encodeUtf8 command) `catchError` (pure . CRChatError Nothing) + +handleRecv :: MonadUnliftIO m => Int -> TBQueue ChatResponse -> m RemoteResponse +handleRecv time events = do + logDebug $ "Recv: " <> tshow time + RRChatEvent <$> (timeout time . atomically $ readTBQueue events) + +-- TODO this command could remember stored files and return IDs to allow removing files that are not needed. +-- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files). +handleStoreFile :: forall m. ChatMonad m => RemoteCrypto -> FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse +handleStoreFile encryption fileName fileSize fileDigest getChunk = + either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile) + where + storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath) + storeFile = \case + Just ff -> takeFileName <$$> storeFileTo ff + Nothing -> storeFileTo =<< getDefaultFilesFolder + storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath) + storeFileTo dir = liftRC . tryRemoteError $ do + filePath <- dir `uniqueCombine` fileName + receiveEncryptedFile encryption getChunk fileSize fileDigest filePath + pure filePath + +handleGetFile :: ChatMonad m => RemoteCrypto -> User -> RemoteFile -> Respond m -> m () +handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do + logDebug $ "GetFile: " <> tshow filePath + unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId} + path <- maybe filePath ( filePath) <$> chatReadVar filesFolder + withStore $ \db -> do + cf <- getLocalCryptoFile db commandUserId fileId sent + unless (cf == cf') $ throwError $ SEFileNotFound fileId + liftRC (tryRemoteError $ getFileInfo path) >>= \case + Left e -> reply (RRProtocolError e) $ \_ -> pure () + Right (fileSize, fileDigest) -> + withFile path ReadMode $ \h -> do + encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize) + reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile + +listRemoteCtrls :: ChatMonad m => m [RemoteCtrlInfo] +listRemoteCtrls = do + session <- snd <$$> chatReadVar remoteCtrlSession + let rcId = sessionRcId =<< session + sessState = rcsSessionState <$> session + map (rcInfo rcId sessState) <$> withStore' getRemoteCtrls + where + rcInfo :: Maybe RemoteCtrlId -> Maybe RemoteCtrlSessionState -> RemoteCtrl -> RemoteCtrlInfo + rcInfo rcId sessState rc@RemoteCtrl {remoteCtrlId} = + remoteCtrlInfo rc $ if rcId == Just remoteCtrlId then sessState else Nothing + sessionRcId = \case + RCSessionConnecting {remoteCtrlId_} -> remoteCtrlId_ + RCSessionPendingConfirmation {remoteCtrlId_} -> remoteCtrlId_ + RCSessionConnected {remoteCtrlId} -> Just remoteCtrlId + _ -> Nothing + +remoteCtrlInfo :: RemoteCtrl -> Maybe RemoteCtrlSessionState -> RemoteCtrlInfo +remoteCtrlInfo RemoteCtrl {remoteCtrlId, ctrlDeviceName} sessionState = + RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} + +-- | Take a look at emoji of tlsunique, commit pairing, and start session server +verifyRemoteCtrlSession :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteCtrlInfo +verifyRemoteCtrlSession execChatCommand sessCode' = do + (sseq, client, ctrlName, sessionCode, vars) <- + chatReadVar remoteCtrlSession >>= \case + Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive + Just (sseq, RCSessionPendingConfirmation {rcsClient, ctrlDeviceName = ctrlName, sessionCode, rcsWaitConfirmation}) -> pure (sseq, rcsClient, ctrlName, sessionCode, rcsWaitConfirmation) + _ -> throwError $ ChatErrorRemoteCtrl RCEBadState + handleCtrlError sseq "verifyRemoteCtrlSession" $ do + let verified = sameVerificationCode sessCode' sessionCode + timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout . liftIO $ confirmCtrlSession client verified -- signal verification result before crashing + unless verified $ throwError $ ChatErrorRemoteCtrl $ RCEProtocolError PRESessionCode + (rcsSession@RCCtrlSession {tls, sessionKeys}, rcCtrlPairing) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) networkIOTimeout $ takeRCStep vars + rc@RemoteCtrl {remoteCtrlId} <- upsertRemoteCtrl ctrlName rcCtrlPairing + remoteOutputQ <- asks (tbqSize . config) >>= newTBQueueIO + encryption <- mkCtrlRemoteCrypto sessionKeys $ tlsUniq tls + http2Server <- async $ attachHTTP2Server tls $ handleRemoteCommand execChatCommand encryption remoteOutputQ + void . forkIO $ monitor sseq http2Server + updateRemoteCtrlSession sseq $ \case + RCSessionPendingConfirmation {} -> Right RCSessionConnected {remoteCtrlId, rcsClient = client, rcsSession, tls, http2Server, remoteOutputQ} + _ -> Left $ ChatErrorRemoteCtrl RCEBadState + pure $ remoteCtrlInfo rc $ Just RCSConnected {sessionCode = tlsSessionCode tls} + where + upsertRemoteCtrl :: ChatMonad m => Text -> RCCtrlPairing -> m RemoteCtrl + upsertRemoteCtrl ctrlName rcCtrlPairing = withStore $ \db -> do + rc_ <- liftIO $ getRemoteCtrlByFingerprint db (ctrlFingerprint rcCtrlPairing) + case rc_ of + Nothing -> insertRemoteCtrl db ctrlName rcCtrlPairing >>= getRemoteCtrl db + Just rc@RemoteCtrl {ctrlPairing} -> do + let dhPrivKey' = dhPrivKey rcCtrlPairing + liftIO $ updateRemoteCtrl db rc ctrlName dhPrivKey' + pure rc {ctrlDeviceName = ctrlName, ctrlPairing = ctrlPairing {dhPrivKey = dhPrivKey'}} + monitor :: ChatMonad m => SessionSeq -> Async () -> m () + monitor sseq server = do + res <- waitCatch server + logInfo $ "HTTP2 server stopped: " <> tshow res + cancelActiveRemoteCtrl (Just sseq) + +stopRemoteCtrl :: ChatMonad m => m () +stopRemoteCtrl = cancelActiveRemoteCtrl Nothing + +handleCtrlError :: ChatMonad m => SessionSeq -> Text -> m a -> m a +handleCtrlError sseq name action = + action `catchChatError` \e -> do + logError $ name <> " remote ctrl error: " <> tshow e + cancelActiveRemoteCtrl (Just sseq) + throwError e + +-- | Stop session controller, unless session update key is present but stale +cancelActiveRemoteCtrl :: ChatMonad m => Maybe SessionSeq -> m () +cancelActiveRemoteCtrl sseq_ = handleAny (logError . tshow) $ do + var <- asks remoteCtrlSession + session_ <- atomically $ readTVar var >>= \case + Nothing -> pure Nothing + Just (oldSeq, _) | maybe False (/= oldSeq) sseq_ -> pure Nothing + Just (_, s) -> Just s <$ writeTVar var Nothing + forM_ session_ $ \session -> do + liftIO $ cancelRemoteCtrl handlingError session + when handlingError $ toView CRRemoteCtrlStopped + where + handlingError = isJust sseq_ + +cancelRemoteCtrl :: Bool -> RemoteCtrlSession -> IO () +cancelRemoteCtrl handlingError = \case + RCSessionStarting -> pure () + RCSessionSearching {action} -> uninterruptibleCancel action + RCSessionConnecting {rcsClient, rcsWaitSession} -> do + unless handlingError $ uninterruptibleCancel rcsWaitSession + cancelCtrlClient rcsClient + RCSessionPendingConfirmation {rcsClient, tls, rcsWaitSession} -> do + unless handlingError $ uninterruptibleCancel rcsWaitSession + cancelCtrlClient rcsClient + closeConnection tls + RCSessionConnected {rcsClient, tls, http2Server} -> do + unless handlingError $ uninterruptibleCancel http2Server + cancelCtrlClient rcsClient + closeConnection tls + +deleteRemoteCtrl :: ChatMonad m => RemoteCtrlId -> m () +deleteRemoteCtrl rcId = do + checkNoRemoteCtrlSession + -- TODO check it exists + withStore' (`deleteRemoteCtrlRecord` rcId) + +checkNoRemoteCtrlSession :: ChatMonad m => m () +checkNoRemoteCtrlSession = + chatReadVar remoteCtrlSession >>= maybe (pure ()) (\_ -> throwError $ ChatErrorRemoteCtrl RCEBusy) + +-- | Transition controller to a new state, unless session update key is stale +updateRemoteCtrlSession :: ChatMonad m => SessionSeq -> (RemoteCtrlSession -> Either ChatError RemoteCtrlSession) -> m () +updateRemoteCtrlSession sseq state = do + session <- asks remoteCtrlSession + r <- atomically $ do + readTVar session >>= \case + Nothing -> pure . Left $ ChatErrorRemoteCtrl RCEInactive + Just (oldSeq, st) + | oldSeq /= sseq -> pure . Left $ ChatErrorRemoteCtrl RCEBadState + | otherwise -> case state st of + Left ce -> pure $ Left ce + Right st' -> Right () <$ writeTVar session (Just (sseq, st')) + liftEither r + +utf8String :: [Char] -> ByteString +utf8String = encodeUtf8 . T.pack +{-# INLINE utf8String #-} diff --git a/src/Simplex/Chat/Remote/AppVersion.hs b/src/Simplex/Chat/Remote/AppVersion.hs new file mode 100644 index 000000000..e39a64b0a --- /dev/null +++ b/src/Simplex/Chat/Remote/AppVersion.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Remote.AppVersion + ( AppVersionRange (minVersion, maxVersion), + pattern AppVersionRange, + AppVersion (..), + pattern AppCompatible, + mkAppVersionRange, + compatibleAppVersion, + isAppCompatible, + ) + where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ +import qualified Data.Text as T +import Data.Version (parseVersion, showVersion) +import qualified Data.Version as V +import Simplex.Messaging.Parsers (defaultJSON) +import Text.ParserCombinators.ReadP (readP_to_S) + +newtype AppVersion = AppVersion {appVersion :: V.Version} + deriving (Eq, Ord, Show) + +instance ToJSON AppVersion where + toJSON (AppVersion v) = J.String . T.pack $ showVersion v + toEncoding (AppVersion v) = JE.text . T.pack $ showVersion v + +instance FromJSON AppVersion where + parseJSON = J.withText "AppVersion" $ parse . T.unpack + where + parse s = case filter (null . snd) $ readP_to_S parseVersion s of + (v, _) : _ -> pure $ AppVersion v + _ -> fail $ "bad AppVersion: " <> s + +data AppVersionRange = AppVRange + { minVersion :: AppVersion, + maxVersion :: AppVersion + } + deriving (Show) + +pattern AppVersionRange :: AppVersion -> AppVersion -> AppVersionRange +pattern AppVersionRange v1 v2 <- AppVRange v1 v2 + +{-# COMPLETE AppVersionRange #-} + +mkAppVersionRange :: AppVersion -> AppVersion -> AppVersionRange +mkAppVersionRange v1 v2 + | v1 <= v2 = AppVRange v1 v2 + | otherwise = error "invalid version range" + +newtype AppCompatible a = AppCompatible_ a + +pattern AppCompatible :: a -> AppCompatible a +pattern AppCompatible a <- AppCompatible_ a + +{-# COMPLETE AppCompatible #-} + +isAppCompatible :: AppVersion -> AppVersionRange -> Bool +isAppCompatible v (AppVRange v1 v2) = v1 <= v && v <= v2 + +isCompatibleAppRange :: AppVersionRange -> AppVersionRange -> Bool +isCompatibleAppRange (AppVRange min1 max1) (AppVRange min2 max2) = min1 <= max2 && min2 <= max1 + +compatibleAppVersion :: AppVersionRange -> AppVersionRange -> Maybe (AppCompatible AppVersion) +compatibleAppVersion vr1 vr2 = + min (maxVersion vr1) (maxVersion vr2) `mkCompatibleIf` isCompatibleAppRange vr1 vr2 + +mkCompatibleIf :: AppVersion -> Bool -> Maybe (AppCompatible AppVersion) +v `mkCompatibleIf` cond = if cond then Just $ AppCompatible_ v else Nothing + +$(JQ.deriveJSON defaultJSON ''AppVersionRange) diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc new file mode 100644 index 000000000..3919b4423 --- /dev/null +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -0,0 +1,46 @@ +module Simplex.Chat.Remote.Multicast (setMembership) where + +import Foreign (Ptr, allocaBytes, castPtr, pokeByteOff) +import Foreign.C.Types (CInt (..)) +import Network.Socket + +#include + +{- | Toggle multicast group membership. + +NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups. +-} +setMembership :: Socket -> HostAddress -> Bool -> IO (Either CInt ()) +setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do + #{poke struct ip_mreq, imr_multiaddr} mReqPtr group + #{poke struct ip_mreq, imr_interface} mReqPtr (0 :: HostAddress) -- attempt to contact the group on ANY interface + withFdSocket sock $ \fd -> do + rc <- c_setsockopt fd c_IPPROTO_IP flag (castPtr mReqPtr) (#{size struct ip_mreq}) + if rc == 0 + then pure $ Right () + else pure $ Left rc + where + flag = if membership then c_IP_ADD_MEMBERSHIP else c_IP_DROP_MEMBERSHIP + +#ifdef mingw32_HOST_OS + +foreign import stdcall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = 12 +c_IP_DROP_MEMBERSHIP = 13 + +#else + +foreign import ccall unsafe "setsockopt" + c_setsockopt :: CInt -> CInt -> CInt -> Ptr CInt -> CInt -> IO CInt + +c_IP_ADD_MEMBERSHIP, c_IP_DROP_MEMBERSHIP :: CInt +c_IP_ADD_MEMBERSHIP = #const IP_ADD_MEMBERSHIP +c_IP_DROP_MEMBERSHIP = #const IP_DROP_MEMBERSHIP + +#endif + +c_IPPROTO_IP :: CInt +c_IPPROTO_IP = #const IPPROTO_IP diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs new file mode 100644 index 000000000..c1acee1e0 --- /dev/null +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -0,0 +1,292 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TupleSections #-} + +module Simplex.Chat.Remote.Protocol where + +import Control.Monad +import Control.Monad.Except +import Control.Monad.Reader +import Crypto.Hash (SHA512) +import qualified Crypto.Hash as CH +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Key as JK +import qualified Data.Aeson.KeyMap as JM +import Data.Aeson.TH (deriveJSON) +import qualified Data.Aeson.Types as JT +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.ByteString.Builder (Builder, byteString, lazyByteString) +import qualified Data.ByteString.Lazy as LB +import Data.String (fromString) +import Data.Text (Text) +import Data.Text.Encoding (decodeUtf8) +import Data.Word (Word32) +import qualified Network.HTTP.Types as N +import qualified Network.HTTP2.Client as H +import Network.Transport.Internal (decodeWord32, encodeWord32) +import Simplex.Chat.Controller +import Simplex.Chat.Remote.Transport +import Simplex.Chat.Remote.Types +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Messaging.Agent.Client (agentDRG) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile (..)) +import Simplex.Messaging.Crypto.Lazy (LazyByteString) +import Simplex.Messaging.Encoding +import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) +import Simplex.Messaging.Transport.Buffer (getBuffered) +import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) +import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow) +import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode) +import Simplex.RemoteControl.Client (xrcpBlockSize) +import qualified Simplex.RemoteControl.Client as RC +import System.FilePath (takeFileName, ()) +import UnliftIO + +data RemoteCommand + = RCSend {command :: Text} -- TODO maybe ChatCommand here? + | RCRecv {wait :: Int} -- this wait should be less than HTTP timeout + | -- local file encryption is determined by the host, but can be overridden for videos + RCStoreFile {fileName :: String, fileSize :: Word32, fileDigest :: FileDigest} -- requires attachment + | RCGetFile {file :: RemoteFile} + deriving (Show) + +data RemoteResponse + = RRChatResponse {chatResponse :: ChatResponse} + | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout + | RRFileStored {filePath :: String} + | RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest + | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side + deriving (Show) + +-- Force platform-independent encoding as the types aren't UI-visible +$(deriveJSON (taggedObjectJSON $ dropPrefix "RC") ''RemoteCommand) +$(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse) + +-- * Client side / desktop + +mkRemoteHostClient :: ChatMonad m => HTTP2Client -> HostSessKeys -> SessionCode -> FilePath -> HostAppInfo -> m RemoteHostClient +mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {encoding, deviceName, encryptFiles} = do + drg <- asks $ agentDRG . smpAgent + counter <- newTVarIO 1 + let HostSessKeys {hybridKey, idPrivKey, sessPrivKey} = sessionKeys + signatures = RSSign {idPrivKey, sessPrivKey} + encryption = RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} + pure + RemoteHostClient + { hostEncoding = encoding, + hostDeviceName = deviceName, + httpClient, + encryption, + encryptHostFiles = encryptFiles, + storePath + } + +mkCtrlRemoteCrypto :: ChatMonad m => CtrlSessKeys -> SessionCode -> m RemoteCrypto +mkCtrlRemoteCrypto CtrlSessKeys {hybridKey, idPubKey, sessPubKey} sessionCode = do + drg <- asks $ agentDRG . smpAgent + counter <- newTVarIO 1 + let signatures = RSVerify {idPubKey, sessPubKey} + pure RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} + +closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m () +closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient + +-- ** Commands + +remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse +remoteSend c cmd = + sendRemoteCommand' c Nothing RCSend {command = decodeUtf8 cmd} >>= \case + RRChatResponse cr -> pure cr + r -> badResponse r + +remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse) +remoteRecv c ms = + sendRemoteCommand' c Nothing RCRecv {wait = ms} >>= \case + RRChatEvent cr_ -> pure cr_ + r -> badResponse r + +remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath +remoteStoreFile c localPath fileName = do + (fileSize, fileDigest) <- getFileInfo localPath + let send h = sendRemoteCommand' c (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest} + withFile localPath ReadMode send >>= \case + RRFileStored {filePath = filePath'} -> pure filePath' + r -> badResponse r + +remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () +remoteGetFile c@RemoteHostClient{encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = + sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case + (getChunk, RRFile {fileSize, fileDigest}) -> do + -- TODO we could optimize by checking size and hash before receiving the file + let localPath = destDir takeFileName filePath + receiveEncryptedFile encryption getChunk fileSize fileDigest localPath + (_, r) -> badResponse r + +-- TODO validate there is no attachment in response +sendRemoteCommand' :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse +sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc + +sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse) +sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do + encFile_ <- mapM (prepareEncryptedFile encryption) file_ + req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd) + HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing + (header, getNext) <- parseDecryptHTTP2Body encryption response respBody + rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecode header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding + pure (getNext, rr) + where + httpRequest encFile_ cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do + send cmdBld + forM_ encFile_ (`sendEncryptedFile` send) + flush + +badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a +badResponse = \case + RRProtocolError e -> throwError e + -- TODO handle chat errors? + r -> throwError $ RPEUnexpectedResponse $ tshow r + +-- * Transport-level wrappers + +convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value +convertJSON _remote@PEKotlin _local@PEKotlin = id +convertJSON PESwift PESwift = id +convertJSON PESwift PEKotlin = owsf2tagged +convertJSON PEKotlin PESwift = error "unsupported convertJSON: K/S" -- guarded by handshake + +-- | Convert swift single-field sum encoding into tagged/discriminator-field +owsf2tagged :: J.Value -> J.Value +owsf2tagged = fst . convert + where + convert val = case val of + J.Object o + | JM.size o == 2 -> + case JM.toList o of + [OwsfTag, o'] -> tagged o' + [o', OwsfTag] -> tagged o' + _ -> props + | otherwise -> props + where + props = (J.Object $ fmap owsf2tagged o, False) + J.Array a -> (J.Array $ fmap owsf2tagged a, False) + _ -> (val, False) + -- `tagged` converts the pair of single-field object encoding to tagged encoding. + -- It sets innerTag returned by `convert` to True to prevent the tag being overwritten. + tagged (k, v) = (J.Object pairs, True) + where + (v', innerTag) = convert v + pairs = case v' of + -- `innerTag` indicates that internal object already has tag, + -- so the current tag cannot be inserted into it. + J.Object o + | innerTag -> pair + | otherwise -> JM.insert TaggedObjectJSONTag tag o + _ -> pair + tag = J.String $ JK.toText k + pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v'] + +pattern OwsfTag :: (JK.Key, J.Value) +pattern OwsfTag = (SingleFieldJSONTag, J.Bool True) + +-- ``` +-- commandBody = encBody sessSignature idSignature (attachment / noAttachment) +-- responseBody = encBody attachment; should match counter in the command +-- encBody = nonce encLength32 encrypted(tlsunique counter body) +-- attachment = %x01 nonce encLength32 encrypted(attachment) +-- noAttachment = %x00 +-- tlsunique = length 1*OCTET +-- nonce = 24*24 OCTET +-- counter = 8*8 OCTET ; int64 +-- encLength32 = 4*4 OCTET ; uint32, includes authTag +-- ``` + +-- See https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md for encoding + +encryptEncodeHTTP2Body :: RemoteCrypto -> LazyByteString -> ExceptT RemoteProtocolError IO Builder +encryptEncodeHTTP2Body RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} s = do + corrId <- atomically $ stateTVar counter $ \c -> (c, c + 1) + let pfx = smpEncode (sessionCode, corrId) + (nonce, ct) <- liftError PRERemoteControl $ RC.rcEncryptBody drg hybridKey $ LB.fromStrict pfx <> s + let ctLen = encodeWord32 (fromIntegral $ LB.length ct) + signed = LB.fromStrict (smpEncode nonce <> ctLen) <> ct + sigs <- bodySignatures signed + pure $ lazyByteString signed <> sigs + where + bodySignatures :: LazyByteString -> ExceptT RemoteProtocolError IO Builder + bodySignatures signed = case signatures of + RSSign {idPrivKey, sessPrivKey} -> do + let hc = CH.hashUpdates (CH.hashInit @SHA512) (LB.toChunks signed) + ssig = sign sessPrivKey hc + idsig = sign idPrivKey $ CH.hashUpdate hc ssig + pure $ byteString $ smpEncode (ssig, idsig) + _ -> pure mempty + sign :: C.PrivateKeyEd25519 -> CH.Context SHA512 -> ByteString + sign k = C.signatureBytes . C.sign' k . BA.convert . CH.hashFinalize + +-- | Parse and decrypt HTTP2 request/response +parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (LazyByteString, Int -> IO ByteString) +parseDecryptHTTP2Body RemoteCrypto {hybridKey, sessionCode, signatures} hr HTTP2Body {bodyBuffer} = do + (nonce, ct) <- getBody + s <- liftError PRERemoteControl $ RC.rcDecryptBody hybridKey nonce ct + (,getNext) <$> parseBody s + where + getBody :: ExceptT RemoteProtocolError IO (C.CbNonce, LazyByteString) + getBody = do + nonceStr <- liftIO $ getNext 24 + nonce <- liftEitherWith RPEInvalidBody $ smpDecode nonceStr + ctLenStr <- liftIO $ getNext 4 + let ctLen = decodeWord32 ctLenStr + when (ctLen > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize + chunks <- liftIO $ getLazy $ fromIntegral ctLen + let hc = CH.hashUpdates (CH.hashInit @SHA512) [nonceStr, ctLenStr] + hc' = CH.hashUpdates hc chunks + verifySignatures hc' + pure (nonce, LB.fromChunks chunks) + getLazy :: Int -> IO [ByteString] + getLazy 0 = pure [] + getLazy n = do + let sz = min n xrcpBlockSize + bs <- getNext sz + let n' = if B.length bs < sz then 0 else max 0 (n - xrcpBlockSize) + (bs :) <$> getLazy n' + verifySignatures :: CH.Context SHA512 -> ExceptT RemoteProtocolError IO () + verifySignatures hc = case signatures of + RSVerify {sessPubKey, idPubKey} -> do + ssig <- getSig + idsig <- getSig + verifySig sessPubKey ssig hc + verifySig idPubKey idsig $ CH.hashUpdate hc $ C.signatureBytes ssig + _ -> pure () + where + getSig = do + len <- liftIO $ B.head <$> getNext 1 + liftEitherError RPEInvalidBody $ C.decodeSignature <$> getNext (fromIntegral len) + verifySig key sig hc' = do + let signed = BA.convert $ CH.hashFinalize hc' + unless (C.verify' key sig signed) $ throwError $ PRERemoteControl RCECtrlAuth + parseBody :: LazyByteString -> ExceptT RemoteProtocolError IO LazyByteString + parseBody s = case LB.uncons s of + Nothing -> throwError $ RPEInvalidBody "empty body" + Just (scLen, rest) -> do + (sessCode', rest') <- takeBytes (fromIntegral scLen) rest + unless (sessCode' == sessionCode) $ throwError PRESessionCode + (_corrId, s') <- takeBytes 8 rest' + pure s' + where + takeBytes n s' = do + let (bs, rest) = LB.splitAt n s' + unless (LB.length bs == n) $ throwError PRESessionCode + pure (LB.toStrict bs, rest) + getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs new file mode 100644 index 000000000..a37d77e20 --- /dev/null +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.RevHTTP where + +import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport.HTTP2 (defaultHTTP2BufferSize, getHTTP2Body) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2ClientError (..), attachHTTP2Client, bodyHeadSize, connTimeout, defaultHTTP2ClientConfig) +import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..), runHTTP2ServerWith) +import Simplex.RemoteControl.Discovery +import UnliftIO + +attachRevHTTP2Client :: IO () -> TLS -> IO (Either HTTP2ClientError HTTP2Client) +attachRevHTTP2Client disconnected = attachHTTP2Client config ANY_ADDR_V4 "0" disconnected defaultHTTP2BufferSize + where + config = defaultHTTP2ClientConfig {bodyHeadSize = doNotPrefetchHead, connTimeout = maxBound} + +attachHTTP2Server :: MonadUnliftIO m => TLS -> (HTTP2Request -> m ()) -> m () +attachHTTP2Server tls processRequest = do + withRunInIO $ \unlift -> + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + reqBody <- getHTTP2Body r doNotPrefetchHead + unlift $ processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + +-- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks +doNotPrefetchHead :: Int +doNotPrefetchHead = 0 diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs new file mode 100644 index 000000000..c5ddfbdb8 --- /dev/null +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -0,0 +1,56 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Remote.Transport where + +import Control.Monad +import Control.Monad.Except +import Data.ByteString.Builder (Builder, byteString) +import Data.ByteString (ByteString) +import qualified Data.ByteString.Lazy as LB +import Data.Word (Word32) +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.Lazy as LC +import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile) +import Simplex.Messaging.Encoding +import Simplex.Messaging.Util (liftEitherError, liftEitherWith) +import Simplex.RemoteControl.Types (RCErrorType (..)) +import UnliftIO +import UnliftIO.Directory (getFileSize) + +type EncryptedFile = ((Handle, Word32), C.CbNonce, LC.SbState) + +prepareEncryptedFile :: RemoteCrypto -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile +prepareEncryptedFile RemoteCrypto {drg, hybridKey} f = do + nonce <- atomically $ C.pseudoRandomCbNonce drg + sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.kcbInit hybridKey nonce + pure (f, nonce, sbState) + +sendEncryptedFile :: EncryptedFile -> (Builder -> IO ()) -> IO () +sendEncryptedFile ((h, sz), nonce, sbState) send = do + send $ byteString $ smpEncode ('\x01', nonce, sz + fromIntegral C.authTagSize) + sendEncFile h send sbState sz + +receiveEncryptedFile :: RemoteCrypto -> (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO () +receiveEncryptedFile RemoteCrypto {hybridKey} getChunk fileSize fileDigest toPath = do + c <- liftIO $ getChunk 1 + unless (c == "\x01") $ throwError RPENoFile + nonce <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 24 + size <- liftEitherError RPEInvalidBody $ smpDecode <$> getChunk 4 + unless (size == fileSize + fromIntegral C.authTagSize) $ throwError RPEFileSize + sbState <- liftEitherWith (const $ PRERemoteControl RCEDecrypt) $ LC.kcbInit hybridKey nonce + liftEitherError fErr $ withFile toPath WriteMode $ \h -> receiveSbFile getChunk h sbState fileSize + digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath + unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest + where + fErr RFESize = RPEFileSize + fErr RFECrypto = PRERemoteControl RCEDecrypt + +getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest) +getFileInfo filePath = do + fileDigest <- liftIO $ FileDigest . LC.sha512Hash <$> LB.readFile filePath + fileSize' <- getFileSize filePath + when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileSize + pure (fromInteger fileSize', fileDigest) diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs new file mode 100644 index 000000000..783a083e5 --- /dev/null +++ b/src/Simplex/Chat/Remote/Types.hs @@ -0,0 +1,202 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Remote.Types where + +import Control.Concurrent.Async (Async) +import Control.Concurrent.STM (TVar) +import Control.Exception (Exception) +import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson.TH as J +import Data.ByteString (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import Simplex.Chat.Remote.AppVersion +import Simplex.Chat.Types (verificationCode) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) +import Simplex.RemoteControl.Client +import Simplex.RemoteControl.Types +import Simplex.Messaging.Crypto.File (CryptoFile) +import Simplex.Messaging.Transport (TLS (..)) + +data RemoteHostClient = RemoteHostClient + { hostEncoding :: PlatformEncoding, + hostDeviceName :: Text, + httpClient :: HTTP2Client, + encryption :: RemoteCrypto, + encryptHostFiles :: Bool, + storePath :: FilePath + } + +data RemoteCrypto = RemoteCrypto + { drg :: TVar ChaChaDRG, + counter :: TVar Int64, + sessionCode :: ByteString, + hybridKey :: KEMHybridSecret, + signatures :: RemoteSignatures + } + +data RemoteSignatures + = RSSign + { idPrivKey :: C.PrivateKeyEd25519, + sessPrivKey :: C.PrivateKeyEd25519 + } + | RSVerify + { idPubKey :: C.PublicKeyEd25519, + sessPubKey :: C.PublicKeyEd25519 + } + +type SessionSeq = Int + +data RHPendingSession = RHPendingSession + { rhKey :: RHKey, + rchClient :: RCHostClient, + rhsWaitSession :: Async (), + remoteHost_ :: Maybe RemoteHostInfo + } + +data RemoteHostSession + = RHSessionStarting + | RHSessionConnecting {invitation :: Text, rhPendingSession :: RHPendingSession} + | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} + | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} + | RHSessionConnected + { rchClient :: RCHostClient, + tls :: TLS, + rhClient :: RemoteHostClient, + pollAction :: Async (), + storePath :: FilePath + } + +data RemoteHostSessionState + = RHSStarting + | RHSConnecting {invitation :: Text} + | RHSPendingConfirmation {sessionCode :: Text} + | RHSConfirmed {sessionCode :: Text} + | RHSConnected {sessionCode :: Text} + deriving (Show) + +rhsSessionState :: RemoteHostSession -> RemoteHostSessionState +rhsSessionState = \case + RHSessionStarting -> RHSStarting + RHSessionConnecting {invitation} -> RHSConnecting {invitation} + RHSessionPendingConfirmation {tls} -> RHSPendingConfirmation {sessionCode = tlsSessionCode tls} + RHSessionConfirmed {tls} -> RHSConfirmed {sessionCode = tlsSessionCode tls} + RHSessionConnected {tls} -> RHSConnected {sessionCode = tlsSessionCode tls} + +tlsSessionCode :: TLS -> Text +tlsSessionCode = verificationCode . tlsUniq + +data RemoteProtocolError + = -- | size prefix is malformed + RPEInvalidSize + | -- | failed to parse RemoteCommand or RemoteResponse + RPEInvalidJSON {invalidJSON :: String} + | RPEInvalidBody {invalidBody :: String} + | PRESessionCode + | RPEIncompatibleEncoding + | RPEUnexpectedFile + | RPENoFile + | RPEFileSize + | RPEFileDigest + | -- | Wrong response received for the command sent + RPEUnexpectedResponse {response :: Text} + | -- | A file already exists in the destination position + RPEStoredFileExists + | PRERemoteControl {rcError :: RCErrorType} + | RPEHTTP2 {http2Error :: Text} + | RPEException {someException :: Text} + deriving (Show, Exception) + +type RemoteHostId = Int64 + +data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} + deriving (Eq, Ord, Show) + +-- | Storable/internal remote host data +data RemoteHost = RemoteHost + { remoteHostId :: RemoteHostId, + hostDeviceName :: Text, + storePath :: FilePath, + hostPairing :: RCHostPairing + } + +-- | UI-accessible remote host information +data RemoteHostInfo = RemoteHostInfo + { remoteHostId :: RemoteHostId, + hostDeviceName :: Text, + storePath :: FilePath, + sessionState :: Maybe RemoteHostSessionState + } + deriving (Show) + +type RemoteCtrlId = Int64 + +-- | Storable/internal remote controller data +data RemoteCtrl = RemoteCtrl + { remoteCtrlId :: RemoteCtrlId, + ctrlDeviceName :: Text, + ctrlPairing :: RCCtrlPairing + } + +remoteCtrlId' :: RemoteCtrl -> RemoteCtrlId +remoteCtrlId' = remoteCtrlId + +data PlatformEncoding + = PESwift + | PEKotlin + deriving (Show, Eq) + +localEncoding :: PlatformEncoding +#if defined(darwin_HOST_OS) && defined(swiftJSON) +localEncoding = PESwift +#else +localEncoding = PEKotlin +#endif + +data RemoteFile = RemoteFile + { userId :: Int64, + fileId :: Int64, + sent :: Bool, + fileSource :: CryptoFile + } + deriving (Show) + +data CtrlAppInfo = CtrlAppInfo + { appVersionRange :: AppVersionRange, + deviceName :: Text + } + deriving (Show) + +data HostAppInfo = HostAppInfo + { appVersion :: AppVersion, + deviceName :: Text, + encoding :: PlatformEncoding, + encryptFiles :: Bool -- if the host encrypts files in app storage + } + +$(J.deriveJSON defaultJSON ''RemoteFile) + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RPE") ''RemoteProtocolError) + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RH") ''RHKey) + +$(J.deriveJSON (enumJSON $ dropPrefix "PE") ''PlatformEncoding) + +$(J.deriveJSON (sumTypeJSON $ dropPrefix "RHS") ''RemoteHostSessionState) + +$(J.deriveJSON defaultJSON ''RemoteHostInfo) + +$(J.deriveJSON defaultJSON ''CtrlAppInfo) + +$(J.deriveJSON defaultJSON ''HostAppInfo) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 5c14100dc..e3b1d4462 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -71,6 +72,7 @@ module Simplex.Chat.Store.Files getSndFileTransfer, getSndFileTransfers, getContactFileInfo, + getLocalCryptoFile, updateDirectCIFileStatus, ) where @@ -602,7 +604,10 @@ getRcvFileTransferById db fileId = do (user,) <$> getRcvFileTransfer db user fileId getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer -getRcvFileTransfer db User {userId} fileId = do +getRcvFileTransfer db User {userId} = getRcvFileTransfer_ db userId + +getRcvFileTransfer_ :: DB.Connection -> UserId -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransfer_ db userId fileId = do rftRow <- ExceptT . firstRow id (SERcvFileNotFound fileId) $ DB.query @@ -808,25 +813,26 @@ getFileTransferProgress db user fileId = do getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer getFileTransfer db user@User {userId} fileId = - fileTransfer =<< liftIO getFileTransferRow + fileTransfer =<< liftIO (getFileTransferRow_ db userId fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId fileTransfer _ = do (ftm, fts) <- getSndFileTransfer db user fileId pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts} - getFileTransferRow :: IO [(Maybe Int64, Maybe Int64)] - getFileTransferRow = - DB.query - db - [sql| - SELECT s.file_id, r.file_id - FROM files f - LEFT JOIN snd_files s ON s.file_id = f.file_id - LEFT JOIN rcv_files r ON r.file_id = f.file_id - WHERE user_id = ? AND f.file_id = ? - |] - (userId, fileId) + +getFileTransferRow_ :: DB.Connection -> UserId -> Int64 -> IO [(Maybe Int64, Maybe Int64)] +getFileTransferRow_ db userId fileId = + DB.query + db + [sql| + SELECT s.file_id, r.file_id + FROM files f + LEFT JOIN snd_files s ON s.file_id = f.file_id + LEFT JOIN rcv_files r ON r.file_id = f.file_id + WHERE user_id = ? AND f.file_id = ? + |] + (userId, fileId) getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer]) getSndFileTransfer db user fileId = do @@ -861,7 +867,10 @@ getSndFileTransfers_ db userId fileId = Nothing -> Left $ SESndFileInvalid fileId getFileTransferMeta :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransferMeta -getFileTransferMeta db User {userId} fileId = +getFileTransferMeta db User {userId} = getFileTransferMeta_ db userId + +getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO FileTransferMeta +getFileTransferMeta_ db userId fileId = ExceptT . firstRow fileTransferMeta (SEFileNotFound fileId) $ DB.query db @@ -883,6 +892,20 @@ getContactFileInfo db User {userId} Contact {contactId} = map toFileInfo <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId) +getLocalCryptoFile :: DB.Connection -> UserId -> Int64 -> Bool -> ExceptT StoreError IO CryptoFile +getLocalCryptoFile db userId fileId sent = + liftIO (getFileTransferRow_ db userId fileId) >>= \case + [(Nothing, Just _)] -> do + when sent $ throwError $ SEFileNotFound fileId + RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId + case fileStatus of + RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs + _ -> throwError $ SEFileNotFound fileId + _ -> do + unless sent $ throwError $ SEFileNotFound fileId + FileTransferMeta {filePath, xftpSndFile} <- getFileTransferMeta_ db userId fileId + pure $ CryptoFile filePath $ xftpSndFile >>= \f -> f.cryptoArgs + updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 59984791d..8e1684259 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1103,7 +1103,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, ciMeta content status = let itemDeleted' = case itemDeleted of DBCINotDeleted -> Nothing - DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) + DBCIBlocked -> Just (CIBlocked deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index e261d97e2..7b9ead1b1 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -89,6 +89,7 @@ import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231113_group_forward +import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -177,7 +178,8 @@ schemaMigrations = ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), - ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward) + ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), + ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c72fbeec2..fbc093062 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,9 +1,10 @@ -{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -58,8 +59,8 @@ module Simplex.Chat.Store.Profiles where import Control.Monad.Except -import Data.Aeson (ToJSON) -import qualified Data.Aeson as J +import Control.Monad.IO.Class +import qualified Data.Aeson.TH as J import Data.Functor (($>)) import Data.Int (Int64) import qualified Data.List.NonEmpty as L @@ -70,7 +71,6 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Protocol @@ -83,6 +83,7 @@ import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (safeDecodeUtf8, eitherToMaybe) @@ -397,17 +398,17 @@ data UserContactLink = UserContactLink { connReqContact :: ConnReqContact, autoAccept :: Maybe AutoAccept } - deriving (Show, Generic) - -instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data AutoAccept = AutoAccept { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } - deriving (Show, Generic) + deriving (Show) -instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions +$(J.deriveJSON defaultJSON ''AutoAccept) + +$(J.deriveJSON defaultJSON ''UserContactLink) toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs new file mode 100644 index 000000000..ec8486037 --- /dev/null +++ b/src/Simplex/Chat/Store/Remote.hs @@ -0,0 +1,143 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Remote where + +import Control.Monad.Except +import Data.Int (Int64) +import Data.Text (Text) +import Database.SQLite.Simple (Only (..)) +import qualified Database.SQLite.Simple as SQL +import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Remote.Types +import Simplex.Chat.Store.Shared +import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.RemoteControl.Types +import UnliftIO + +insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId +insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do + KnownHostPairing {hostFingerprint, hostDhPubKey} <- + maybe (throwError SERemoteHostUnknown) pure kh_ + checkConstraint SERemoteHostDuplicateCA . liftIO $ + DB.execute + db + [sql| + INSERT INTO remote_hosts + (host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + VALUES + (?, ?, ?, ?, ?, ?, ?) + |] + (hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) + liftIO $ insertedRowId db + +getRemoteHosts :: DB.Connection -> IO [RemoteHost] +getRemoteHosts db = + map toRemoteHost <$> DB.query_ db remoteHostQuery + +getRemoteHost :: DB.Connection -> RemoteHostId -> ExceptT StoreError IO RemoteHost +getRemoteHost db remoteHostId = + ExceptT . firstRow toRemoteHost (SERemoteHostNotFound remoteHostId) $ + DB.query db (remoteHostQuery <> " WHERE remote_host_id = ?") (Only remoteHostId) + +getRemoteHostByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteHost) +getRemoteHostByFingerprint db fingerprint = + maybeFirstRow toRemoteHost $ + DB.query db (remoteHostQuery <> " WHERE host_fingerprint = ?") (Only fingerprint) + +remoteHostQuery :: SQL.Query +remoteHostQuery = + [sql| + SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub + FROM remote_hosts + |] + +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost +toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) = + RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing} + where + hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} + knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} + +updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () +updateHostPairing db rhId hostDeviceName hostDhPubKey = + DB.execute + db + [sql| + UPDATE remote_hosts + SET host_device_name = ?, host_dh_pub = ? + WHERE remote_host_id = ? + |] + (hostDeviceName, hostDhPubKey, rhId) + +deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () +deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) + +insertRemoteCtrl :: DB.Connection -> Text -> RCCtrlPairing -> ExceptT StoreError IO RemoteCtrlId +insertRemoteCtrl db ctrlDeviceName RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} = do + checkConstraint SERemoteCtrlDuplicateCA . liftIO $ + DB.execute + db + [sql| + INSERT INTO remote_controllers + (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) + VALUES + (?, ?, ?, ?, ?, ?, ?) + |] + (ctrlDeviceName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) + liftIO $ insertedRowId db + +getRemoteCtrls :: DB.Connection -> IO [RemoteCtrl] +getRemoteCtrls db = + map toRemoteCtrl <$> DB.query_ db remoteCtrlQuery + +getRemoteCtrl :: DB.Connection -> RemoteCtrlId -> ExceptT StoreError IO RemoteCtrl +getRemoteCtrl db remoteCtrlId = + ExceptT . firstRow toRemoteCtrl (SERemoteCtrlNotFound remoteCtrlId) $ + DB.query db (remoteCtrlQuery <> " WHERE remote_ctrl_id = ?") (Only remoteCtrlId) + +getRemoteCtrlByFingerprint :: DB.Connection -> C.KeyHash -> IO (Maybe RemoteCtrl) +getRemoteCtrlByFingerprint db fingerprint = + maybeFirstRow toRemoteCtrl $ + DB.query db (remoteCtrlQuery <> " WHERE ctrl_fingerprint = ?") (Only fingerprint) + +remoteCtrlQuery :: SQL.Query +remoteCtrlQuery = + [sql| + SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key + FROM remote_controllers + |] + +toRemoteCtrl :: + ( RemoteCtrlId, + Text, + C.APrivateSignKey, + C.SignedObject C.Certificate, + C.KeyHash, + C.PublicKeyEd25519, + C.PrivateKeyX25519, + Maybe C.PrivateKeyX25519 + ) -> + RemoteCtrl +toRemoteCtrl (remoteCtrlId, ctrlDeviceName, caKey, C.SignedObject caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey) = + let ctrlPairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint, idPubKey, dhPrivKey, prevDhPrivKey} + in RemoteCtrl {remoteCtrlId, ctrlDeviceName, ctrlPairing} + +updateRemoteCtrl :: DB.Connection -> RemoteCtrl -> Text -> C.PrivateKeyX25519 -> IO () +updateRemoteCtrl db RemoteCtrl {remoteCtrlId} ctrlDeviceName dhPrivKey = + DB.execute + db + [sql| + UPDATE remote_controllers + SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key + WHERE remote_ctrl_id = ? + |] + (ctrlDeviceName, dhPrivKey, remoteCtrlId) + +deleteRemoteCtrlRecord :: DB.Connection -> RemoteCtrlId -> IO () +deleteRemoteCtrlRecord db remoteCtrlId = + DB.execute db "DELETE FROM remote_controllers WHERE remote_ctrl_id = ?" (Only remoteCtrlId) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 0de75e718..9d2da138b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,11 +1,11 @@ {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Shared where @@ -14,8 +14,7 @@ import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except import Crypto.Random (ChaChaDRG, randomBytesGenerate) -import Data.Aeson (ToJSON) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) @@ -26,9 +25,9 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..)) import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) -import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) @@ -99,11 +98,14 @@ data StoreError | SEContactNotFoundByFileId {fileId :: FileTransferId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId} - deriving (Show, Exception, Generic) + | SERemoteHostNotFound {remoteHostId :: RemoteHostId} + | SERemoteHostUnknown -- ^ attempting to store KnownHost without a known fingerprint + | SERemoteHostDuplicateCA + | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} + | SERemoteCtrlDuplicateCA + deriving (Show, Exception) -instance ToJSON StoreError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SE" +$(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e78e23876..5db94bd92 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -52,12 +52,14 @@ getKey = runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do s <- atomically . readTBQueue $ inputQ cc + rh <- readTVarIO $ currentRemoteHost cc let bs = encodeUtf8 $ T.pack s cmd = parseChatCommand bs + rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- runReaderT (execChatCommand bs) cc + r <- runReaderT (execChatCommand rh' bs) cc processResp s cmd r - printRespToTerminal ct cc False r + printRespToTerminal ct cc False rh r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] @@ -145,7 +147,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = +receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -177,7 +179,8 @@ receiveFromTTY cc@ChatController {inputQ, currentUser, chatStore} ct@ChatTermina kill promptThreadId atomically $ writeTVar liveMessageState Nothing r <- sendUpdatedLiveMessage cc sentMsg lm False - printRespToTerminal ct cc False r + rh <- readTVarIO currentRemoteHost -- XXX: should be inherited from live message state + printRespToTerminal ct cc False rh r where kill sel = deRefWeak (sel lm) >>= mapM_ killThread diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index cd6141e04..9daeeb315 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -25,6 +25,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) +import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types @@ -137,7 +138,7 @@ withTermLock ChatTerminal {termLock} action = do runTerminalOutput :: ChatTerminal -> ChatController -> IO () runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do forever $ do - (_, r) <- atomically $ readTBQueue outputQ + (_, outputRH, r) <- atomically $ readTBQueue outputQ case r of CRNewChatItem u ci -> markChatItemRead u ci CRChatItemUpdated u ci -> markChatItemRead u ci @@ -146,7 +147,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d Just path -> if logResponseToFile r then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems - responseString cc liveItems r >>= printResp + responseString cc liveItems outputRH r >>= printResp responseNotification ct cc r where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = @@ -252,15 +253,16 @@ whenCurrUser cc u a = do where sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId -printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () -printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct +printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO () +printRespToTerminal ct cc liveItems outputRH r = responseString cc liveItems outputRH r >>= printToTerminal ct -responseString :: ChatController -> Bool -> ChatResponse -> IO [StyledString] -responseString cc liveItems r = do - user <- readTVarIO $ currentUser cc +responseString :: ChatController -> Bool -> Maybe RemoteHostId -> ChatResponse -> IO [StyledString] +responseString cc liveItems outputRH r = do + currentRH <- readTVarIO $ currentRemoteHost cc + user <- readTVarIO $ currentUser cc -- XXX: local user, should be subsumed by remote when connected ts <- getCurrentTime tz <- getCurrentTimeZone - pure $ responseToView user (config cc) liveItems ts tz r + pure $ responseToView (currentRH, user) (config cc) liveItems ts tz outputRH r printToTerminal :: ChatTerminal -> [StyledString] -> IO () printToTerminal ct s = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index d4968a938..34f2a4f10 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2,7 +2,6 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -10,10 +9,12 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -23,9 +24,10 @@ module Simplex.Chat.Types where import Crypto.Number.Serialize (os2ip) -import Data.Aeson (FromJSON (..), ToJSON (..), (.=)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) @@ -41,14 +43,13 @@ import Database.SQLite.Simple.FromField (returnError, FromField(..)) import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -114,18 +115,14 @@ data User = User sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool } - deriving (Show, Generic, FromJSON) - -instance ToJSON User where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) data NewUser = NewUser { profile :: Maybe Profile, sameServers :: Bool, pastTimestamp :: Bool } - deriving (Show, Generic, FromJSON) + deriving (Show) newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) @@ -146,19 +143,13 @@ instance ToJSON B64UrlByteString where toEncoding = strToJEncoding data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data UserInfo = UserInfo { user :: User, unreadCount :: Int } - deriving (Show, Generic, FromJSON) - -instance ToJSON UserInfo where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type ContactId = Int64 @@ -181,11 +172,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON Contact where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) contactConn :: Contact -> Maybe Connection contactConn Contact {activeConn} = activeConn @@ -231,6 +218,9 @@ instance FromField ContactStatus where fromField = fromTextField_ textDecode instance ToField ContactStatus where toField = toField . textEncode +instance FromJSON ContactStatus where + parseJSON = textParseJSON "ContactStatus" + instance ToJSON ContactStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -250,9 +240,7 @@ data ContactRef = ContactRef agentConnId :: AgentConnId, localDisplayName :: ContactName } - deriving (Eq, Show, Generic) - -instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ContactOrMember = COMContact Contact | COMGroupMember GroupMember deriving (Show) @@ -272,15 +260,11 @@ data UserContact = UserContact connReqContact :: ConnReqContact, groupId :: Maybe GroupId } - deriving (Eq, Show, Generic) + deriving (Eq, Show) userContactGroupId :: UserContact -> Maybe GroupId userContactGroupId UserContact {groupId} = groupId -instance ToJSON UserContact where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - data UserContactRequest = UserContactRequest { contactRequestId :: Int64, agentInvitationId :: AgentInvId, @@ -294,10 +278,7 @@ data UserContactRequest = UserContactRequest updatedAt :: UTCTime, xContactId :: Maybe XContactId } - deriving (Eq, Show, Generic) - -instance ToJSON UserContactRequest where - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) newtype XContactId = XContactId ByteString deriving (Eq, Show) @@ -351,9 +332,7 @@ optionalFullName displayName fullName | otherwise = " (" <> fullName <> ")" data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} - deriving (Eq, Show, Generic) - -instance ToJSON Group where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) type GroupId = Int64 @@ -369,9 +348,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime } - deriving (Eq, Show, Generic) - -instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g @@ -379,9 +356,7 @@ groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary { currentMembers :: Int } - deriving (Show, Generic) - -instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup Group @@ -396,9 +371,7 @@ data ChatSettings = ChatSettings sendRcpts :: Maybe Bool, favorite :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultChatSettings :: ChatSettings defaultChatSettings = @@ -412,18 +385,7 @@ chatHasNtfs :: ChatSettings -> Bool chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone data MsgFilter = MFNone | MFAll | MFMentions - deriving (Eq, Show, Generic) - -instance FromJSON MsgFilter where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" - -instance ToJSON MsgFilter where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" - -instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP - -instance ToField MsgFilter where toField = toField . msgFilterInt + deriving (Eq, Show) msgFilterInt :: MsgFilter -> Int msgFilterInt = \case @@ -506,11 +468,7 @@ data Profile = Profile -- - incognito -- - local_alias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Profile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) profileFromName :: ContactName -> Profile profileFromName displayName = @@ -536,11 +494,7 @@ data LocalProfile = LocalProfile preferences :: Maybe Preferences, localAlias :: LocalAlias } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON LocalProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId localProfileId = profileId @@ -560,11 +514,7 @@ data GroupProfile = GroupProfile image :: Maybe ImageData, groupPreferences :: Maybe GroupPreferences } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupProfile where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) newtype ImageData = ImageData Text deriving (Eq, Show) @@ -581,14 +531,6 @@ instance ToField ImageData where toField (ImageData t) = toField t instance FromField ImageData where fromField = fmap ImageData . fromField data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId} - deriving (Generic) - -instance ToJSON CReqClientData where - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "CRData" - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "CRData" - -instance FromJSON CReqClientData where - parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "CRData" newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link deriving (Eq, Show) @@ -616,11 +558,7 @@ data GroupInvitation = GroupInvitation groupProfile :: GroupProfile, groupLinkId :: Maybe GroupLinkId } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data GroupLinkInvitation = GroupLinkInvitation { fromMember :: MemberIdRole, @@ -628,29 +566,19 @@ data GroupLinkInvitation = GroupLinkInvitation invitedMember :: MemberIdRole, groupProfile :: GroupProfile } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupLinkInvitation where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberIdRole where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data IntroInvitation = IntroInvitation { groupConnReq :: ConnReqInvitation, directConnReq :: Maybe ConnReqInvitation } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON IntroInvitation where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data MemberInfo = MemberInfo { memberId :: MemberId, @@ -658,11 +586,7 @@ data MemberInfo = MemberInfo v :: Maybe ChatVersionRange, profile :: Profile } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON MemberInfo where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = @@ -705,16 +629,10 @@ data GroupMember = GroupMember -- for membership current supportedChatVRange is set, it's not updated on protocol version increase memberChatVRange :: JVersionRange } - deriving (Eq, Show, Generic) - -instance ToJSON GroupMember where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberRef where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = @@ -781,11 +699,7 @@ instance ToJSON MemberId where toEncoding = strToJEncoding data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown - deriving (Eq, Show, Generic) - -instance ToJSON InvitedBy where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "IB" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "IB" + deriving (Eq, Show) toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy toInvitedBy userCtId (Just ctId) @@ -837,9 +751,7 @@ instance ToJSON GroupMemberRole where data GroupMemberSettings = GroupMemberSettings { showMessages :: Bool } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) defaultMemberSettings :: GroupMemberSettings defaultMemberSettings = GroupMemberSettings {showMessages = True} @@ -886,6 +798,9 @@ instance FromField GroupMemberCategory where fromField = fromTextField_ textDeco instance ToField GroupMemberCategory where toField = toField . textEncode +instance FromJSON GroupMemberCategory where + parseJSON = textParseJSON "GroupMemberCategory" + instance ToJSON GroupMemberCategory where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -923,6 +838,9 @@ instance FromField GroupMemberStatus where fromField = fromTextField_ textDecode instance ToField GroupMemberStatus where toField = toField . textEncode +instance FromJSON GroupMemberStatus where + parseJSON = textParseJSON "GroupMemberStatus" + instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1014,9 +932,7 @@ data SndFileTransfer = SndFileTransfer fileDescrId :: Maybe Int64, fileInline :: Maybe InlineFileMode } - deriving (Eq, Show, Generic) - -instance ToJSON SndFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) sndFileTransferConnId :: SndFileTransfer -> ConnId sndFileTransferConnId SndFileTransfer {agentConnId = AgentConnId acId} = acId @@ -1031,24 +947,10 @@ data FileInvitation = FileInvitation fileInline :: Maybe InlineFileMode, fileDescr :: Maybe FileDescr } - deriving (Eq, Show, Generic) - -instance ToJSON FileInvitation where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance FromJSON FileInvitation where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FileDescr = FileDescr {fileDescrText :: Text, fileDescrPartNo :: Int, fileDescrComplete :: Bool} - deriving (Eq, Show, Generic) - -instance ToJSON FileDescr where - toEncoding = J.genericToEncoding J.defaultOptions - toJSON = J.genericToJSON J.defaultOptions - -instance FromJSON FileDescr where - parseJSON = J.genericParseJSON J.defaultOptions + deriving (Eq, Show) xftpFileInvitation :: FilePath -> Integer -> FileDescr -> FileInvitation xftpFileInvitation fileName fileSize fileDescr = @@ -1064,7 +966,7 @@ xftpFileInvitation fileName fileSize fileDescr = data InlineFileMode = IFMOffer -- file will be sent inline once accepted | IFMSent -- file is sent inline without acceptance - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance TextEncoding InlineFileMode where textEncode = \case @@ -1080,7 +982,7 @@ instance FromField InlineFileMode where fromField = fromTextField_ textDecode instance ToField InlineFileMode where toField = toField . textEncode instance FromJSON InlineFileMode where - parseJSON = J.withText "InlineFileMode" $ maybe (fail "bad InlineFileMode") pure . textDecode + parseJSON = textParseJSON "InlineFileMode" instance ToJSON InlineFileMode where toJSON = J.String . textEncode @@ -1100,18 +1002,14 @@ data RcvFileTransfer = RcvFileTransfer -- SMP files are encrypted after all chunks are received cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) - -instance ToJSON RcvFileTransfer where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPRcvFile = XFTPRcvFile { rcvFileDescription :: RcvFileDescr, agentRcvFileId :: Maybe AgentRcvFileId, agentRcvFileDeleted :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON XFTPRcvFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileDescr = RcvFileDescr { fileDescrId :: Int64, @@ -1119,9 +1017,7 @@ data RcvFileDescr = RcvFileDescr fileDescrPartNo :: Int, fileDescrComplete :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON RcvFileDescr where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data RcvFileStatus = RFSNew @@ -1129,11 +1025,7 @@ data RcvFileStatus | RFSConnected RcvFileInfo | RFSComplete RcvFileInfo | RFSCancelled (Maybe RcvFileInfo) - deriving (Eq, Show, Generic) - -instance ToJSON RcvFileStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RFS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RFS" + deriving (Eq, Show) rcvFileComplete :: RcvFileStatus -> Bool rcvFileComplete = \case @@ -1148,9 +1040,7 @@ data RcvFileInfo = RcvFileInfo connId :: Maybe Int64, agentConnId :: Maybe AgentConnId } - deriving (Eq, Show, Generic) - -instance ToJSON RcvFileInfo where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) liveRcvFileTransferInfo :: RcvFileTransfer -> Maybe RcvFileInfo liveRcvFileTransferInfo RcvFileTransfer {fileStatus} = case fileStatus of @@ -1196,6 +1086,9 @@ instance StrEncoding AgentSndFileId where strDecode s = AgentSndFileId <$> strDecode s strP = AgentSndFileId <$> strP +instance FromJSON AgentSndFileId where + parseJSON = strParseJSON "AgentSndFileId" + instance ToJSON AgentSndFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1212,6 +1105,9 @@ instance StrEncoding AgentRcvFileId where strDecode s = AgentRcvFileId <$> strDecode s strP = AgentRcvFileId <$> strP +instance FromJSON AgentRcvFileId where + parseJSON = strParseJSON "AgentRcvFileId" + instance ToJSON AgentRcvFileId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1228,6 +1124,9 @@ instance StrEncoding AgentInvId where strDecode s = AgentInvId <$> strDecode s strP = AgentInvId <$> strP +instance FromJSON AgentInvId where + parseJSON = strParseJSON "AgentInvId" + instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1242,11 +1141,7 @@ data FileTransfer sndFileTransfers :: [SndFileTransfer] } | FTRcv {rcvFileTransfer :: RcvFileTransfer} - deriving (Show, Generic) - -instance ToJSON FileTransfer where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" + deriving (Show) data FileTransferMeta = FileTransferMeta { fileId :: FileTransferId, @@ -1258,9 +1153,7 @@ data FileTransferMeta = FileTransferMeta chunkSize :: Integer, cancelled :: Bool } - deriving (Eq, Show, Generic) - -instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data XFTPSndFile = XFTPSndFile { agentSndFileId :: AgentSndFileId, @@ -1268,9 +1161,7 @@ data XFTPSndFile = XFTPSndFile agentSndFileDeleted :: Bool, cryptoArgs :: Maybe CryptoFileArgs } - deriving (Eq, Show, Generic) - -instance ToJSON XFTPSndFile where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) fileTransferCancelled :: FileTransfer -> Bool fileTransferCancelled (FTSnd FileTransferMeta {cancelled} _) = cancelled @@ -1283,6 +1174,9 @@ instance FromField FileStatus where fromField = fromTextField_ textDecode instance ToField FileStatus where toField = toField . textEncode +instance FromJSON FileStatus where + parseJSON = textParseJSON "FileStatus" + instance ToJSON FileStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1328,7 +1222,7 @@ data Connection = Connection authErrCounter :: Int, createdAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show) connReady :: Connection -> Bool connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady @@ -1340,11 +1234,7 @@ connDisabled :: Connection -> Bool connDisabled Connection {authErrCounter} = authErrCounter >= authErrDisableCount data SecurityCode = SecurityCode {securityCode :: Text, verifiedAt :: UTCTime} - deriving (Eq, Show, Generic) - -instance ToJSON SecurityCode where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) verificationCode :: ByteString -> Text verificationCode = T.pack . unwords . chunks 5 . show . os2ip @@ -1363,10 +1253,6 @@ aConnId Connection {agentConnId = AgentConnId cId} = cId connIncognito :: Connection -> Bool connIncognito Connection {customUserProfileId} = isJust customUserProfileId -instance ToJSON Connection where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - data PendingContactConnection = PendingContactConnection { pccConnId :: Int64, pccAgentConnId :: AgentConnId, @@ -1380,13 +1266,11 @@ data PendingContactConnection = PendingContactConnection createdAt :: UTCTime, updatedAt :: UTCTime } - deriving (Eq, Show, Generic) + deriving (Eq, Show) aConnId' :: PendingContactConnection -> ConnId aConnId' PendingContactConnection {pccAgentConnId = AgentConnId cId} = cId -instance ToJSON PendingContactConnection where toEncoding = J.genericToEncoding J.defaultOptions - data ConnStatus = -- | connection is created by initiating party with agent NEW command (createConnection) ConnNew @@ -1408,6 +1292,9 @@ instance FromField ConnStatus where fromField = fromTextField_ textDecode instance ToField ConnStatus where toField = toField . textEncode +instance FromJSON ConnStatus where + parseJSON = textParseJSON "ConnStatus" + instance ToJSON ConnStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1438,6 +1325,9 @@ instance FromField ConnType where fromField = fromTextField_ textDecode instance ToField ConnType where toField = toField . textEncode +instance FromJSON ConnType where + parseJSON = textParseJSON "ConnType" + instance ToJSON ConnType where toJSON = J.String . textEncode toEncoding = JE.text . textEncode @@ -1515,7 +1405,7 @@ data NetworkStatus | NSConnected | NSDisconnected | NSError {connectionError :: String} - deriving (Eq, Ord, Show, Generic) + deriving (Eq, Ord, Show) netStatusStr :: NetworkStatus -> String netStatusStr = \case @@ -1524,20 +1414,11 @@ netStatusStr = \case NSDisconnected -> "disconnected" NSError e -> "error: " <> e -instance FromJSON NetworkStatus where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" - -instance ToJSON NetworkStatus where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" - data ConnNetworkStatus = ConnNetworkStatus { agentConnId :: AgentConnId, networkStatus :: NetworkStatus } - deriving (Show, Generic, FromJSON) - -instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Show) type CommandId = Int64 @@ -1551,7 +1432,7 @@ data CommandStatus = CSCreated | CSCompleted -- unused - was replaced with deleteCommand | CSError -- internal command error, e.g. not matching connection id or unexpected response, not related to agent message ERR - deriving (Show, Generic) + deriving (Show) instance FromField CommandStatus where fromField = fromTextField_ textDecode @@ -1578,7 +1459,7 @@ data CommandFunction | CFAcceptContact | CFAckMessage | CFDeleteConn -- not used - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField CommandFunction where fromField = fromTextField_ textDecode @@ -1644,14 +1525,7 @@ data ServerCfg p = ServerCfg tested :: Maybe Bool, enabled :: Bool } - deriving (Show, Generic) - -instance ProtocolTypeI p => ToJSON (ServerCfg p) where - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - -instance ProtocolTypeI p => FromJSON (ServerCfg p) where - parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + deriving (Show) newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRange} deriving (Eq, Show) @@ -1667,6 +1541,107 @@ instance ToJSON ChatVersionRange where newtype JVersionRange = JVersionRange {fromJVersionRange :: VersionRange} deriving (Eq, Show) +instance FromJSON JVersionRange where + parseJSON = J.withObject "JVersionRange" $ \o -> do + minv <- o .: "minVersion" + maxv <- o .: "maxVersion" + maybe (fail "bad version range") (pure . JVersionRange) $ safeVersionRange minv maxv + instance ToJSON JVersionRange where toJSON (JVersionRange (VersionRange minV maxV)) = J.object ["minVersion" .= minV, "maxVersion" .= maxV] toEncoding (JVersionRange (VersionRange minV maxV)) = J.pairs $ "minVersion" .= minV <> "maxVersion" .= maxV + +$(JQ.deriveJSON defaultJSON ''UserContact) + +$(JQ.deriveJSON defaultJSON ''Profile) + +$(JQ.deriveJSON defaultJSON ''LocalProfile) + +$(JQ.deriveJSON defaultJSON ''UserContactRequest) + +$(JQ.deriveJSON defaultJSON ''GroupProfile) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) + +$(JQ.deriveJSON defaultJSON ''GroupMemberSettings) + +$(JQ.deriveJSON defaultJSON ''SecurityCode) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NS") ''NetworkStatus) + +$(JQ.deriveJSON defaultJSON ''ConnNetworkStatus) + +$(JQ.deriveJSON defaultJSON ''Connection) + +$(JQ.deriveJSON defaultJSON ''PendingContactConnection) + +$(JQ.deriveJSON defaultJSON ''GroupMember) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) + +$(JQ.deriveJSON defaultJSON ''ChatSettings) + +$(JQ.deriveJSON defaultJSON ''GroupInfo) + +$(JQ.deriveJSON defaultJSON ''Group) + +$(JQ.deriveJSON defaultJSON ''GroupSummary) + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "CRData") ''CReqClientData) + +$(JQ.deriveJSON defaultJSON ''MemberIdRole) + +$(JQ.deriveJSON defaultJSON ''GroupInvitation) + +$(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) + +$(JQ.deriveJSON defaultJSON ''IntroInvitation) + +$(JQ.deriveJSON defaultJSON ''MemberInfo) + +$(JQ.deriveJSON defaultJSON ''GroupMemberRef) + +$(JQ.deriveJSON defaultJSON ''FileDescr) + +$(JQ.deriveJSON defaultJSON ''FileInvitation) + +$(JQ.deriveJSON defaultJSON ''SndFileTransfer) + +$(JQ.deriveJSON defaultJSON ''RcvFileDescr) + +$(JQ.deriveJSON defaultJSON ''XFTPRcvFile) + +$(JQ.deriveJSON defaultJSON ''RcvFileInfo) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RFS") ''RcvFileStatus) + +$(JQ.deriveJSON defaultJSON ''RcvFileTransfer) + +$(JQ.deriveJSON defaultJSON ''XFTPSndFile) + +$(JQ.deriveJSON defaultJSON ''FileTransferMeta) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FT") ''FileTransfer) + +$(JQ.deriveJSON defaultJSON ''UserPwdHash) + +$(JQ.deriveJSON defaultJSON ''User) + +$(JQ.deriveJSON defaultJSON ''NewUser) + +$(JQ.deriveJSON defaultJSON ''UserInfo) + +$(JQ.deriveJSON defaultJSON ''Contact) + +$(JQ.deriveJSON defaultJSON ''ContactRef) + +instance ProtocolTypeI p => ToJSON (ServerCfg p) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerCfg) + toJSON = $(JQ.mkToJSON defaultJSON ''ServerCfg) + +instance ProtocolTypeI p => FromJSON (ServerCfg p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerCfg) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index a89e38324..5410fd42a 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -11,6 +10,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -21,7 +21,7 @@ module Simplex.Chat.Types.Preferences where import Control.Applicative ((<|>)) import Data.Aeson (FromJSON (..), ToJSON (..)) -import qualified Data.Aeson as J +import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) @@ -29,11 +29,10 @@ import Data.Text (Text) import qualified Data.Text as T import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import GHC.Generics (Generic) import GHC.Records.Compat import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) data ChatFeature @@ -42,7 +41,7 @@ data ChatFeature | CFReactions | CFVoice | CFCalls - deriving (Show, Generic) + deriving (Show) data SChatFeature (f :: ChatFeature) where SCFTimedMessages :: SChatFeature 'CFTimedMessages @@ -68,13 +67,6 @@ chatFeatureNameText = \case chatFeatureNameText' :: SChatFeature f -> Text chatFeatureNameText' = chatFeatureNameText . chatFeature -instance ToJSON ChatFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CF" - -instance FromJSON ChatFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CF" - allChatFeatures :: [AChatFeature] allChatFeatures = [ ACF SCFTimedMessages, @@ -146,17 +138,7 @@ data Preferences = Preferences voice :: Maybe VoicePreference, calls :: Maybe CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Preferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField Preferences where - toField = toField . encodeJSON - -instance FromField Preferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) data GroupFeature = GFTimedMessages @@ -165,7 +147,7 @@ data GroupFeature | GFReactions | GFVoice | GFFiles - deriving (Show, Generic) + deriving (Show) data SGroupFeature (f :: GroupFeature) where SGFTimedMessages :: SGroupFeature 'GFTimedMessages @@ -197,13 +179,6 @@ groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferenc groupFeatureAllowed' feature prefs = getField @"enable" (getGroupPreference feature prefs) == FEOn -instance ToJSON GroupFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "GF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "GF" - -instance FromJSON GroupFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "GF" - allGroupFeatures :: [AGroupFeature] allGroupFeatures = [ AGF SGFTimedMessages, @@ -260,17 +235,7 @@ data GroupPreferences = GroupPreferences voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField GroupPreferences where - toField = toField . encodeJSON - -instance FromField GroupPreferences where - fromField = fromTextField_ decodeJSON + deriving (Eq, Show) setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs @@ -309,9 +274,7 @@ data FullPreferences = FullPreferences voice :: VoicePreference, calls :: CallsPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing -- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here. @@ -323,9 +286,7 @@ data FullGroupPreferences = FullGroupPreferences voice :: VoiceGroupPreference, files :: FilesGroupPreference } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) -- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences data ContactUserPreferences = ContactUserPreferences @@ -335,25 +296,17 @@ data ContactUserPreferences = ContactUserPreferences voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } - deriving (Eq, Show, Generic) + deriving (Eq, Show) data ContactUserPreference p = ContactUserPreference { enabled :: PrefEnabled, userPreference :: ContactUserPref p, contactPreference :: p } - deriving (Eq, Show, Generic) + deriving (Eq, Show) data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p} - deriving (Eq, Show, Generic) - -instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPref p) where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" + deriving (Eq, Show) toChatPrefs :: FullPreferences -> Preferences toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = @@ -396,31 +349,19 @@ data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON TimedMessagesPreference where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + deriving (Eq, Show) data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data VoicePreference = VoicePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON VoicePreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) data CallsPreference = CallsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallsPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (FeaturePreference f), HasField "allow" (FeaturePreference f) FeatureAllowed) => FeatureI f where type FeaturePreference (f :: ChatFeature) = p | p -> f @@ -469,47 +410,33 @@ instance FeatureI 'CFCalls where data GroupPreference = GroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data TimedMessagesGroupPreference = TimedMessagesGroupPreference { enable :: GroupFeatureEnabled, ttl :: Maybe Int } - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data DirectMessagesGroupPreference = DirectMessagesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data ReactionsGroupPreference = ReactionsGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data VoiceGroupPreference = VoiceGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) + deriving (Eq, Show) data FilesGroupPreference = FilesGroupPreference {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FilesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f @@ -611,7 +538,7 @@ data FeatureAllowed = FAAlways -- allow unconditionally | FAYes -- allow, if peer allows it | FANo -- do not allow - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode @@ -637,7 +564,7 @@ instance ToJSON FeatureAllowed where toEncoding = strToJEncoding data GroupFeatureEnabled = FEOn | FEOff - deriving (Eq, Show, Generic) + deriving (Eq, Show) instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode @@ -710,11 +637,7 @@ toGroupPreferences groupPreferences = pref f = Just $ getGroupPreference f groupPreferences data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON PrefEnabled where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + deriving (Eq, Show) prefEnabled :: FeatureI f => Bool -> FeaturePreference f -> FeaturePreference f -> PrefEnabled prefEnabled asymmetric user contact = case (getField @"allow" user, getField @"allow" contact) of @@ -776,3 +699,69 @@ getContactUserPreference = \case SCFReactions -> reactions SCFVoice -> voice SCFCalls -> calls + +$(J.deriveJSON (enumJSON $ dropPrefix "CF") ''ChatFeature) + +$(J.deriveJSON (enumJSON $ dropPrefix "GF") ''GroupFeature) + +$(J.deriveJSON defaultJSON ''TimedMessagesPreference) + +$(J.deriveJSON defaultJSON ''FullDeletePreference) + +$(J.deriveJSON defaultJSON ''ReactionsPreference) + +$(J.deriveJSON defaultJSON ''VoicePreference) + +$(J.deriveJSON defaultJSON ''CallsPreference) + +$(J.deriveJSON defaultJSON ''Preferences) + +instance ToField Preferences where + toField = toField . encodeJSON + +instance FromField Preferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''GroupPreference) + +$(J.deriveJSON defaultJSON ''TimedMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''DirectMessagesGroupPreference) + +$(J.deriveJSON defaultJSON ''ReactionsGroupPreference) + +$(J.deriveJSON defaultJSON ''FullDeleteGroupPreference) + +$(J.deriveJSON defaultJSON ''VoiceGroupPreference) + +$(J.deriveJSON defaultJSON ''FilesGroupPreference) + +$(J.deriveJSON defaultJSON ''GroupPreferences) + +instance ToField GroupPreferences where + toField = toField . encodeJSON + +instance FromField GroupPreferences where + fromField = fromTextField_ decodeJSON + +$(J.deriveJSON defaultJSON ''FullPreferences) + +$(J.deriveJSON defaultJSON ''FullGroupPreferences) + +$(J.deriveJSON defaultJSON ''PrefEnabled) + +instance FromJSON p => FromJSON (ContactUserPref p) where + parseJSON = $(J.mkParseJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance ToJSON p => ToJSON (ContactUserPref p) where + toJSON = $(J.mkToJSON (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + toEncoding = $(J.mkToEncoding (sumTypeJSON $ dropPrefix "CUP") ''ContactUserPref) + +instance FromJSON p => FromJSON (ContactUserPreference p) where + parseJSON = $(J.mkParseJSON defaultJSON ''ContactUserPreference) + +instance ToJSON p => ToJSON (ContactUserPreference p) where + toJSON = $(J.mkToJSON defaultJSON ''ContactUserPreference) + toEncoding = $(J.mkToEncoding defaultJSON ''ContactUserPreference) + +$(J.deriveJSON defaultJSON ''ContactUserPreferences) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index cbde25cc9..60d2a4cba 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,18 +1,20 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.View where -import Data.Aeson (ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) @@ -30,7 +32,7 @@ import Data.Time (LocalTime (..), TimeOfDay (..), TimeZone (..), utcToLocalTime) import Data.Time.Calendar (addDays) import Data.Time.Clock (UTCTime) import Data.Time.Format (defaultTimeLocale, formatTime) -import GHC.Generics (Generic) +import qualified Data.Version as V import qualified Network.HTTP.Types as Q import Numeric (showFFloat) import Simplex.Chat (defaultChatConfig, maxImageSize) @@ -41,6 +43,8 @@ import Simplex.Chat.Markdown import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol +import Simplex.Chat.Remote.Types +import Simplex.Chat.Remote.AppVersion (pattern AppVersionRange, AppVersion (..)) import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types @@ -64,11 +68,18 @@ import System.Console.ANSI.Types type CurrentTime = UTCTime -serializeChatResponse :: Maybe User -> CurrentTime -> TimeZone -> ChatResponse -> String -serializeChatResponse user_ ts tz = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz +data WCallCommand + = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} + | WCCallAnswer {answer :: Text, iceCandidates :: Text} -responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> ChatResponse -> [StyledString] -responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz = \case +$(JQ.deriveToJSON (taggedObjectJSON $ dropPrefix "WCCall") ''WCallCommand) + +serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> String +serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_ + +responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] +responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -182,10 +193,10 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupMemberUpdated {} -> [] CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -266,6 +277,52 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] + CRCurrentRemoteHost rhi_ -> + [ maybe + "Using local profile" + (\RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> "Using remote host " <> sShow rhId <> " (" <> plain hostDeviceName <> ")") + rhi_ + ] + CRRemoteHostList hs -> viewRemoteHosts hs + CRRemoteHostStarted {remoteHost_, invitation} -> + [ maybe "new remote host started" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " started") remoteHost_, + "Remote session invitation:", + plain invitation + ] + CRRemoteHostSessionCode {remoteHost_, sessionCode} -> + [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, + "Compare session code with host:", + plain sessionCode + ] + CRNewRemoteHost RemoteHostInfo {remoteHostId = rhId, hostDeviceName} -> ["new remote host " <> sShow rhId <> " added: " <> plain hostDeviceName] + CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"] + CRRemoteHostStopped rhId_ -> + [ maybe "new remote host" (mappend "remote host " . sShow) rhId_ <> " stopped" + ] + CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> + [plain $ "file " <> filePath <> " stored on remote host " <> show rhId] + <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ + CRRemoteCtrlList cs -> viewRemoteCtrls cs + CRRemoteCtrlFound rc -> + ["remote controller found:", viewRemoteCtrl rc] + CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo = CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (AppVersion ctrlVersion)}, appVersion = AppVersion v} -> + [ (maybe "connecting new remote controller" (\RemoteCtrlInfo {remoteCtrlId} -> "connecting remote controller " <> sShow remoteCtrlId) remoteCtrl_ <> ": ") + <> (if T.null deviceName then "" else plain deviceName <> ", ") + <> ("v" <> plain (V.showVersion ctrlVersion) <> ctrlVersionInfo) + ] + where + ctrlVersionInfo + | ctrlVersion < v = " (older than this app - upgrade controller)" + | ctrlVersion > v = " (newer than this app - upgrade it)" + | otherwise = "" + CRRemoteCtrlSessionCode {remoteCtrl_, sessionCode} -> + [ maybe "new remote controller connected" (\RemoteCtrlInfo {remoteCtrlId} -> "remote controller " <> sShow remoteCtrlId <> " connected") remoteCtrl_, + "Compare session code with controller and use:", + "/verify remote ctrl " <> plain sessionCode -- TODO maybe pass rcId + ] + CRRemoteCtrlConnected RemoteCtrlInfo {remoteCtrlId = rcId, ctrlDeviceName} -> + ["remote controller " <> sShow rcId <> " session started with " <> plain ctrlDeviceName] + CRRemoteCtrlStopped -> ["remote controller stopped"] CRSQLResult rows -> map plain rows CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = @@ -313,12 +370,14 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView | otherwise = [] ttyUserPrefix :: User -> [StyledString] -> [StyledString] ttyUserPrefix _ [] = [] - ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + ttyUserPrefix User {userId, localDisplayName = u} ss + | null prefix = ss + | otherwise = prependFirst ("[" <> mconcat prefix <> "] ") ss where - userPrefix = case user_ of - Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" - _ -> prefix - prefix = "[user: " <> highlight u <> "] " + prefix = intersperse ", " $ remotePrefix <> userPrefix + remotePrefix = [maybe "local" (("remote: " <>) . highlight . show) outputRH | outputRH /= currentRH] + userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] + currentUserId = (\User {userId = uId} -> uId) <$> user_ ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] @@ -428,7 +487,7 @@ viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String -showSMPServer = B.unpack . strEncode . host +showSMPServer srv = B.unpack $ strEncode srv.host viewHostEvent :: AProtocolType -> TransportHost -> String viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) @@ -1480,18 +1539,25 @@ savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] -receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = - [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ +receivingFile_' :: (Maybe RemoteHostId, Maybe User) -> Bool -> String -> AChatItem -> [StyledString] +receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just f@(CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ <> getRemoteFileStr where - cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] - where - s = - if testView - then LB.toStrict $ J.encode cfArgs - else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"] cfArgsStr _ = [] -receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + getRemoteFileStr = case hu of + (Just rhId, Just User {userId}) | status == "completed" -> + [ "File received to connected remote host " <> sShow rhId, + "To download to this device use:", + highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f})) + ] + _ -> [] +receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +cryptoFileArgsStr :: Bool -> CryptoFileArgs -> ByteString +cryptoFileArgsStr testView cfArgs@(CFArgs key nonce) + | testView = LB.toStrict $ J.encode cfArgs + | otherwise = "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce fileFrom :: ChatInfo c -> CIDirection c d -> StyledString fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct @@ -1625,16 +1691,6 @@ supporedBrowsers callType | encryptedCall callType = " (only Chrome and Safari support e2e encryption for WebRTC, Safari may require enabling WebRTC insertable streams)" | otherwise = "" -data WCallCommand - = WCCallStart {media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallOffer {offer :: Text, iceCandidates :: Text, media :: CallMedia, aesKey :: Maybe String, useWorker :: Bool} - | WCCallAnswer {answer :: Text, iceCandidates :: Text} - deriving (Generic) - -instance ToJSON WCallCommand where - toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "WCCall" - toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "WCCall" - viewVersionInfo :: ChatLogLevel -> CoreVersionInfo -> [StyledString] viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCommit} = map plain $ @@ -1644,6 +1700,39 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo where parens s = " (" <> s <> ")" +viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] +viewRemoteHosts = \case + [] -> ["No remote hosts"] + hs -> "Remote hosts: " : map viewRemoteHostInfo hs + where + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RHSStarting -> " (starting)" + RHSConnecting _ -> " (connecting)" + RHSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RHSConfirmed _ -> " (confirmed)" + RHSConnected _ -> " (connected)" + +viewRemoteCtrls :: [RemoteCtrlInfo] -> [StyledString] +viewRemoteCtrls = \case + [] -> ["No remote controllers"] + hs -> "Remote controllers: " : map viewRemoteCtrlInfo hs + where + viewRemoteCtrlInfo RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName, sessionState} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName <> maybe "" viewSessionState sessionState + viewSessionState = \case + RCSStarting -> " (starting)" + RCSSearching -> " (searching)" + RCSConnecting -> " (connecting)" + RCSPendingConfirmation {sessionCode} -> " (pending confirmation, code: " <> sessionCode <> ")" + RCSConnected _ -> " (connected)" + +-- TODO fingerprint, accepted? +viewRemoteCtrl :: RemoteCtrlInfo -> StyledString +viewRemoteCtrl RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName} = + plain $ tshow remoteCtrlId <> ". " <> ctrlDeviceName + viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case ChatError err -> case err of @@ -1753,6 +1842,8 @@ viewChatError logLevel = \case SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text] SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> viewGroupName g)] SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)] + SERemoteCtrlNotFound rcId -> ["no remote controller " <> sShow rcId] + SERemoteHostNotFound rhId -> ["no remote host " <> sShow rhId] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] @@ -1787,7 +1878,11 @@ viewChatError logLevel = \case Just entity@(UserContactConnection conn UserContact {userContactLinkId}) -> "[" <> connEntityLabel entity <> ", userContactLinkId: " <> sShow userContactLinkId <> ", connId: " <> cId conn <> "] " Nothing -> "" + cId :: Connection -> StyledString cId conn = sShow (connId (conn :: Connection)) + ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] + ChatErrorRemoteHost RHNew e -> [plain $ "new remote host error: " <> show e] + ChatErrorRemoteHost (RHId rhId) e -> [plain $ "remote host " <> show rhId <> " error: " <> show e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] sqliteError' = \case diff --git a/stack.yaml b/stack.yaml index 4e2503a3b..352698bd3 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 20d9767c5474de083b711cc034c871af3b57f6f7 + commit: 6926b45703715659887cda166fc1f78f9ef614f9 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 761d4af20..bdad4a03e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -89,7 +89,7 @@ termSettings :: VirtualTerminalSettings termSettings = VirtualTerminalSettings { virtualType = "xterm", - virtualWindowSize = pure C.Size {height = 24, width = 1000}, + virtualWindowSize = pure C.Size {height = 24, width = 2250}, virtualEvent = retry, virtualInterrupt = retry } diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index d58227b1d..83b0d507b 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -36,6 +36,9 @@ import Test.Hspec defaultPrefs :: Maybe Preferences defaultPrefs = Just $ toChatPrefs defaultChatPrefs +aliceDesktopProfile :: Profile +aliceDesktopProfile = Profile {displayName = "alice_desktop", fullName = "Alice Desktop", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + aliceProfile :: Profile aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} @@ -324,6 +327,9 @@ cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line ($<#) :: HasCallStack => (TestCC, String) -> String -> Expectation (cc, uName) $<# line = (dropTime . dropUser uName <$> getTermLine cc) `shouldReturn` line +(^<#) :: HasCallStack => (TestCC, String) -> String -> Expectation +(cc, p) ^<# line = (dropTime . dropStrPrefix p <$> getTermLine cc) `shouldReturn` line + (⩗) :: HasCallStack => TestCC -> String -> Expectation cc ⩗ line = (dropTime . dropReceipt <$> getTermLine cc) `shouldReturn` line diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs new file mode 100644 index 000000000..a17a69fae --- /dev/null +++ b/tests/JSONTests.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TemplateHaskell #-} + +module JSONTests where + +import Control.Monad (join) +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT +import Data.ByteString.Builder (toLazyByteString) +import qualified Data.ByteString.Lazy.Char8 as LB +import GHC.Generics (Generic) +import Generic.Random (genericArbitraryU) +import MobileTests +import Simplex.Chat.Remote.Protocol (owsf2tagged) +import Simplex.Messaging.Parsers +import Test.Hspec +import Test.Hspec.QuickCheck (modifyMaxSuccess) +import Test.QuickCheck (Arbitrary (..), property) + +owsf2TaggedJSONTest :: IO () +owsf2TaggedJSONTest = do + noActiveUserSwift `to` noActiveUserTagged + activeUserExistsSwift `to` activeUserExistsTagged + activeUserSwift `to` activeUserTagged + chatStartedSwift `to` chatStartedTagged + networkStatusesSwift `to` networkStatusesTagged + memberSubSummarySwift `to` memberSubSummaryTagged + userContactSubSummarySwift `to` userContactSubSummaryTagged + pendingSubSummarySwift `to` pendingSubSummaryTagged + parsedMarkdownSwift `to` parsedMarkdownTagged + where + to :: LB.ByteString -> LB.ByteString -> IO () + owsf `to` tagged = + case J.eitherDecode owsf of + Right json -> Right (owsf2tagged json) `shouldBe` J.eitherDecode tagged + Left e -> expectationFailure e + +data SomeType + = Nullary + | Unary (Maybe SomeType) + | Product String (Maybe SomeType) + | Record + { testOne :: Int, + testTwo :: Maybe Bool, + testThree :: Maybe SomeType + } + | List [Int] + deriving (Eq, Show, Generic) + +$(pure []) + +thToJSON :: SomeType -> J.Value +thToJSON = $(JQ.mkToJSON (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thToEncoding :: SomeType -> J.Encoding +thToEncoding = $(JQ.mkToEncoding (singleFieldJSON_ (Just SingleFieldJSONTag) id) ''SomeType) + +thParseJSON :: J.Value -> JT.Parser SomeType +thParseJSON = $(JQ.mkParseJSON (taggedObjectJSON id) ''SomeType) + +instance Arbitrary SomeType where arbitrary = genericArbitraryU + +instance ToJSON SomeType where + toJSON = J.genericToJSON $ singleFieldJSON_ (Just SingleFieldJSONTag) id + toEncoding = J.genericToEncoding $ singleFieldJSON_ (Just SingleFieldJSONTag) id + +instance FromJSON SomeType where + parseJSON = J.genericParseJSON $ taggedObjectJSON id + +owsf2TaggedSomeTypeTests :: Spec +owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do + it "should convert to tagged" $ property $ \x -> + (JT.parseMaybe J.parseJSON . owsf2tagged . J.toJSON) x == Just (x :: SomeType) + it "should convert to tagged via encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe J.parseJSON . owsf2tagged) . J.decode . J.encode) x == Just (x :: SomeType) + it "should convert to tagged via TH" $ property $ \x -> + (JT.parseMaybe thParseJSON . owsf2tagged . thToJSON) x == Just (x :: SomeType) + it "should convert to tagged via TH encoding" $ property $ \x -> + (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) + +jsonTests :: Spec +jsonTests = describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index aa36b397a..d8e98513c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -9,8 +10,9 @@ module MobileTests where import ChatTests.Utils import Control.Monad.Except import Crypto.Random (getRandomBytes) -import Data.Aeson (FromJSON (..)) +import Data.Aeson (FromJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BS @@ -256,9 +258,11 @@ testMediaCApi _ = do (f cKeyStr ptr cLen >>= peekCAString) `shouldReturn` "" getByteString ptr cLen -instance FromJSON WriteFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "WF" +instance FromJSON WriteFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "WF") ''WriteFileResult) -instance FromJSON ReadFileResult where parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RF" +instance FromJSON ReadFileResult where + parseJSON = $(JQ.mkParseJSON (sumTypeJSON $ dropPrefix "RF") ''ReadFileResult) testFileCApi :: FilePath -> FilePath -> IO () testFileCApi fileName tmp = do diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs new file mode 100644 index 000000000..c734c94db --- /dev/null +++ b/tests/RemoteTests.hs @@ -0,0 +1,519 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module RemoteTests where + +import ChatClient +import ChatTests.Utils +import Control.Logger.Simple +import qualified Data.Aeson as J +import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.Map.Strict as M +import qualified Network.TLS as TLS +import Simplex.Chat.Archive (archiveFilesFolder) +import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..)) +import qualified Simplex.Chat.Controller as Controller +import Simplex.Chat.Mobile.File +import Simplex.Chat.Remote.Types +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials) +import Simplex.Messaging.Util +import System.FilePath (()) +import Test.Hspec +import UnliftIO +import UnliftIO.Concurrent +import UnliftIO.Directory + +remoteTests :: SpecWith FilePath +remoteTests = describe "Remote" $ do + describe "protocol handshake" $ do + it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False + it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True + it "connects with stored pairing" remoteHandshakeStoredTest + it "connects with multicast discovery" remoteHandshakeDiscoverTest + it "refuses invalid client cert" remoteHandshakeRejectTest + it "sends messages" remoteMessageTest + describe "remote files" $ do + it "store/get/send/receive files" remoteStoreFileTest + it "should send files from CLI without /store" remoteCLIFileTest + it "switches remote hosts" switchRemoteHostTest + it "indicates remote hosts" indicateRemoteHostTest + +-- * Chat commands + +remoteHandshakeTest :: HasCallStack => Bool -> FilePath -> IO () +remoteHandshakeTest viaDesktop = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + + startRemote mobile desktop + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. Mobile (connected)" + + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. My desktop (connected)" + + if viaDesktop then stopDesktop mobile desktop else stopMobile mobile desktop + + desktop ##> "/delete remote host 1" + desktop <## "ok" + desktop ##> "/list remote hosts" + desktop <## "No remote hosts" + + mobile ##> "/delete remote ctrl 1" + mobile <## "ok" + mobile ##> "/list remote ctrls" + mobile <## "No remote controllers" + +remoteHandshakeStoredTest :: HasCallStack => FilePath -> IO () +remoteHandshakeStoredTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + logNote "Starting new session" + startRemote mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + logNote "Starting stored session" + startRemoteStored mobile desktop + stopDesktop mobile desktop `catchAny` (logError . tshow) + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <## "1. Mobile" + mobile ##> "/list remote ctrls" + mobile <## "Remote controllers:" + mobile <## "1. My desktop" + + logNote "Starting stored session again" + startRemoteStored mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + +remoteHandshakeDiscoverTest :: HasCallStack => FilePath -> IO () +remoteHandshakeDiscoverTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + logNote "Preparing new session" + startRemote mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + + logNote "Starting stored session with multicast" + startRemoteDiscover mobile desktop + stopMobile mobile desktop `catchAny` (logError . tshow) + +remoteHandshakeRejectTest :: HasCallStack => FilePath -> IO () +remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop mobileBob -> do + logNote "Starting new session" + startRemote mobile desktop + stopMobile mobile desktop + + mobileBob ##> "/set device name MobileBob" + mobileBob <## "ok" + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobileBob ##> ("/connect remote ctrl " <> inv) + mobileBob <## "connecting new remote controller: My desktop, v5.4.0.3" + mobileBob <## "remote controller stopped" + + -- the server remains active after rejecting invalid client + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "remote controller 1 connected" + mobile <## "Compare session code with controller and use:" + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + stopMobile mobile desktop + +remoteMessageTest :: HasCallStack => FilePath -> IO () +remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + startRemote mobile desktop + contactBob desktop bob + + logNote "sending messages" + desktop #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" + bob #> "@alice hi" + desktop <# "bob> hi" + + logNote "post-remote checks" + stopMobile mobile desktop + + mobile ##> "/contacts" + mobile <## "bob (Bob)" + + bob ##> "/contacts" + bob <## "alice (Alice)" + + desktop ##> "/contacts" + -- empty contact list on desktop-local + + threadDelay 1000000 + logNote "done" + +remoteStoreFileTest :: HasCallStack => FilePath -> IO () +remoteStoreFileTest = + testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> + withXFTPServer $ do + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let desktopFiles = "./tests/tmp/desktop_files" + desktop ##> ("/_files_folder " <> desktopFiles) + desktop <## "ok" + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) + desktop <## "ok" + let bobFiles = "./tests/tmp/bob_files" + bob ##> ("/_files_folder " <> bobFiles) + bob <## "ok" + + startRemote mobile desktop + contactBob desktop bob + + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopHostStore <- case M.lookup (RHId 1) rhs of + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + _ -> fail "Host session 1 should be started" + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test.pdf stored on remote host 1" + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test_1.pdf stored on remote host 1" + B.readFile (mobileFiles "test_1.pdf") `shouldReturn` src + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + desktop ##> "/store remote file 1 encrypt=on tests/fixtures/test.pdf" + desktop <## "file test_2.pdf stored on remote host 1" + Just cfArgs@(CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine desktop + chatReadFile (mobileFiles "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + chatReadFile (desktopHostStore "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + + removeFile (desktopHostStore "test_1.pdf") + removeFile (desktopHostStore "test_2.pdf") + + -- cannot get file before it is used + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- send file not encrypted locally on mobile host + desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}" + desktop <# "@bob sending a file" + desktop <# "/f @bob test_1.pdf" + desktop <## "use /fc 1 to cancel sending" + bob <# "alice> sending a file" + bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + concurrentlyN_ + [ do + desktop <## "completed uploading file 1 (test_1.pdf) for bob", + do + bob <## "saving file 1 from alice to test_1.pdf" + bob <## "started receiving file 1 (test_1.pdf) from alice" + bob <## "completed receiving file 1 (test_1.pdf) from alice" + ] + B.readFile (bobFiles "test_1.pdf") `shouldReturn` src + -- returns error for inactive user + desktop ##> "/get remote file 1 {\"userId\": 2, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "CEDifferentActiveUser" + -- returns error with incorrect file ID + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 2, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + hostError desktop "SEFileNotFound" + -- gets file + doesFileExist (desktopHostStore "test_1.pdf") `shouldReturn` False + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}" + desktop <## "ok" + B.readFile (desktopHostStore "test_1.pdf") `shouldReturn` src + + -- send file encrypted locally on mobile host + desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}") + desktop <# "/f @bob test_2.pdf" + desktop <## "use /fc 2 to cancel sending" + bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> "/fr 2" + concurrentlyN_ + [ do + desktop <## "completed uploading file 2 (test_2.pdf) for bob", + do + bob <## "saving file 2 from alice to test_2.pdf" + bob <## "started receiving file 2 (test_2.pdf) from alice" + bob <## "completed receiving file 2 (test_2.pdf) from alice" + ] + B.readFile (bobFiles "test_2.pdf") `shouldReturn` src + + -- receive file via remote host + copyFile "./tests/fixtures/test.jpg" (bobFiles "test.jpg") + bob #> "/f @alice test.jpg" + bob <## "use /fc 3 to cancel sending" + desktop <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)" + desktop <## "use /fr 3 [/ | ] to receive it" + desktop ##> "/fr 3 encrypt=on" + concurrentlyN_ + [ do + bob <## "completed uploading file 3 (test.jpg) for alice", + do + desktop <## "saving file 3 from bob to test.jpg" + desktop <## "started receiving file 3 (test.jpg) from bob" + desktop <## "completed receiving file 3 (test.jpg) from bob" + ] + Just cfArgs'@(CFArgs key' nonce') <- J.decode . LB.pack <$> getTermLine desktop + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + getCmd `shouldBe` ("/get remote file 1 {\"userId\":1,\"fileId\":3,\"sent\":false,\"fileSource\":{\"filePath\":\"test.jpg\",\"cryptoArgs\":" <> LB.unpack (J.encode cfArgs') <> "}}") + src' <- B.readFile (bobFiles "test.jpg") + chatReadFile (mobileFiles "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + -- returns error with incorrect key + desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 3, \"sent\": false, \"fileSource\": {\"filePath\": \"test.jpg\", \"cryptoArgs\": null}}" + hostError desktop "SEFileNotFound" + doesFileExist (desktopHostStore "test.jpg") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + chatReadFile (desktopHostStore "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src') + + stopMobile mobile desktop + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} + hostError cc err = do + r <- getTermLine cc + r `shouldStartWith` "remote host 1 error" + r `shouldContain` err + +remoteCLIFileTest :: HasCallStack => FilePath -> IO () +remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do + createDirectoryIfMissing True "./tests/tmp/tmp/" + let mobileFiles = "./tests/tmp/mobile_files" + mobile ##> ("/_files_folder " <> mobileFiles) + mobile <## "ok" + let bobFiles = "./tests/tmp/bob_files/" + createDirectoryIfMissing True bobFiles + let desktopHostFiles = "./tests/tmp/remote_hosts_data" + desktop ##> ("/remote_hosts_folder " <> desktopHostFiles) + desktop <## "ok" + + startRemote mobile desktop + contactBob desktop bob + + rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) + desktopHostStore <- case M.lookup (RHId 1) rhs of + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + _ -> fail "Host session 1 should be started" + + mobileName <- userName mobile + + bob #> ("/f @" <> mobileName <> " " <> "tests/fixtures/test.pdf") + bob <## "use /fc 1 to cancel sending" + + desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + desktop <## "use /fr 1 [/ | ] to receive it" + desktop ##> "/fr 1" + concurrentlyN_ + [ do + bob <## "completed uploading file 1 (test.pdf) for alice", + do + desktop <## "saving file 1 from bob to test.pdf" + desktop <## "started receiving file 1 (test.pdf) from bob" + desktop <## "completed receiving file 1 (test.pdf) from bob" + ] + + desktop <## "File received to connected remote host 1" + desktop <## "To download to this device use:" + getCmd <- getTermLine desktop + src <- B.readFile "tests/fixtures/test.pdf" + B.readFile (mobileFiles "test.pdf") `shouldReturn` src + doesFileExist (desktopHostStore "test.pdf") `shouldReturn` False + desktop ##> getCmd + desktop <## "ok" + B.readFile (desktopHostStore "test.pdf") `shouldReturn` src + + desktop `send` "/f @bob tests/fixtures/test.jpg" + desktop <# "/f @bob test.jpg" + desktop <## "use /fc 2 to cancel sending" + + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 2 [/ | ] to receive it" + bob ##> ("/fr 2 " <> bobFiles) + concurrentlyN_ + [ do + desktop <## "completed uploading file 2 (test.jpg) for bob", + do + bob <## "saving file 2 from alice to ./tests/tmp/bob_files/test.jpg" + bob <## "started receiving file 2 (test.jpg) from alice" + bob <## "completed receiving file 2 (test.jpg) from alice" + ] + + src' <- B.readFile "tests/fixtures/test.jpg" + B.readFile (mobileFiles "test.jpg") `shouldReturn` src' + B.readFile (desktopHostStore "test.jpg") `shouldReturn` src' + B.readFile (bobFiles "test.jpg") `shouldReturn` src' + + stopMobile mobile desktop + where + cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"} + +switchRemoteHostTest :: FilePath -> IO () +switchRemoteHostTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do + startRemote mobile desktop + contactBob desktop bob + + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop ##> "/contacts" + + desktop ##> "/switch remote host 1" + desktop <## "Using remote host 1 (Mobile)" + desktop ##> "/contacts" + desktop <## "bob (Bob)" + + desktop ##> "/switch remote host 123" + desktop <## "no remote host 123" + + stopDesktop mobile desktop + desktop ##> "/contacts" + desktop ##> "/switch remote host 1" + desktop <## "remote host 1 error: RHEInactive" + desktop ##> "/contacts" + +indicateRemoteHostTest :: FilePath -> IO () +indicateRemoteHostTest = testChat4 aliceProfile aliceDesktopProfile bobProfile cathProfile $ \mobile desktop bob cath -> do + connectUsers desktop cath + startRemote mobile desktop + contactBob desktop bob + -- remote contact -> remote host + bob #> "@alice hi" + desktop <#. "bob> hi" + -- local -> remote + cath #> "@alice_desktop hello" + (desktop, "[local] ") ^<# "cath> hello" + -- local -> local + desktop ##> "/switch remote host local" + desktop <## "Using local profile" + desktop <##> cath + -- local -> remote + bob #> "@alice what's up?" + (desktop, "[remote: 1] ") ^<# "bob> what's up?" + + -- local -> local after disconnect + stopDesktop mobile desktop + desktop <##> cath + cath <##> desktop + +-- * Utils + +startRemote :: TestCC -> TestCC -> IO () +startRemote mobile desktop = do + desktop ##> "/set device name My desktop" + desktop <## "ok" + mobile ##> "/set device name Mobile" + mobile <## "ok" + desktop ##> "/start remote host new" + desktop <## "new remote host started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## "connecting new remote controller: My desktop, v5.4.0.3" + desktop <## "new remote host connecting" + mobile <## "new remote controller connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "new remote host 1 added: Mobile" + desktop <## "remote host 1 connected" + +startRemoteStored :: TestCC -> TestCC -> IO () +startRemoteStored mobile desktop = do + desktop ##> "/start remote host 1" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +startRemoteDiscover :: TestCC -> TestCC -> IO () +startRemoteDiscover mobile desktop = do + desktop ##> "/start remote host 1 multicast=on" + desktop <## "remote host 1 started" + desktop <## "Remote session invitation:" + _inv <- getTermLine desktop -- will use multicast instead + mobile ##> "/find remote ctrl" + mobile <## "ok" + mobile <## "remote controller found:" + mobile <## "1. My desktop" + mobile ##> "/confirm remote ctrl 1" + + mobile <## "connecting remote controller 1: My desktop, v5.4.0.3" + desktop <## "remote host 1 connecting" + mobile <## "remote controller 1 connected" + verifyRemoteCtrl mobile desktop + mobile <## "remote controller 1 session started with My desktop" + desktop <## "remote host 1 connected" + +verifyRemoteCtrl :: TestCC -> TestCC -> IO () +verifyRemoteCtrl mobile desktop = do + desktop <## "Compare session code with host:" + sessId <- getTermLine desktop + mobile <## "Compare session code with controller and use:" + mobile <## ("/verify remote ctrl " <> sessId) + mobile ##> ("/verify remote ctrl " <> sessId) + +contactBob :: TestCC -> TestCC -> IO () +contactBob desktop bob = do + logNote "exchanging contacts" + bob ##> "/c" + inv' <- getInvitation bob + desktop ##> ("/c " <> inv') + desktop <## "confirmation sent!" + concurrently_ + (desktop <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + +genTestCredentials :: IO (C.KeyHash, TLS.Credentials) +genTestCredentials = do + caCreds <- liftIO $ genCredentials Nothing (0, 24) "CA" + sessionCreds <- liftIO $ genCredentials (Just caCreds) (0, 24) "Session" + pure . tlsCredentials $ sessionCreds :| [caCreds] + +stopDesktop :: HasCallStack => TestCC -> TestCC -> IO () +stopDesktop mobile desktop = do + logWarn "stopping via desktop" + desktop ##> "/stop remote host 1" + desktop <## "ok" + eventually 3 $ mobile <## "remote controller stopped" + +stopMobile :: HasCallStack => TestCC -> TestCC -> IO () +stopMobile mobile desktop = do + logWarn "stopping via mobile" + mobile ##> "/stop remote ctrl" + mobile <## "ok" + eventually 3 $ desktop <## "remote host 1 stopped" + +-- | Run action with extended timeout +eventually :: Int -> IO a -> IO a +eventually retries action = + tryAny action >>= \case + -- TODO: only catch timeouts + Left err | retries == 0 -> throwIO err + Left _ -> eventually (retries - 1) action + Right r -> pure r diff --git a/tests/Test.hs b/tests/Test.hs index cf60a7013..568f9688d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -5,9 +5,11 @@ import ChatTests import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import JSONTests import MarkdownTests import MobileTests import ProtocolTests +import RemoteTests import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) @@ -17,10 +19,11 @@ import WebRTCTests main :: IO () main = do - setLogLevel LogError -- LogDebug + setLogLevel LogError withGlobalLogging logCfg . hspec $ do describe "Schema dump" schemaDumpTest describe "SimpleX chat markdown" markdownTests + describe "JSON Tests" jsonTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "WebRTC encryption" webRTCTests @@ -30,6 +33,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests + describe "Remote session" remoteTests where testBracket test = do t <- getSystemTime diff --git a/tests/ViewTests.hs b/tests/ViewTests.hs index 7c7a2f0e0..085a56af4 100644 --- a/tests/ViewTests.hs +++ b/tests/ViewTests.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE BlockArguments #-} {-# LANGUAGE OverloadedStrings #-} module ViewTests where