diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a801560558..37734a9e47 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -264,7 +264,7 @@ jobs: # * 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' + if: matrix.os == 'windows-latest' uses: msys2/setup-msys2@v2 with: msystem: ucrt64 diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4c0f361024..13fe0737e2 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -83,7 +83,7 @@ final class ChatModel: ObservableObject { // current WebRTC call @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? - @Published var callCommand: WCallCommand? + let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor() @Published var showCallView = false // remote desktop @Published var remoteCtrlSession: RemoteCtrlSession? @@ -267,7 +267,20 @@ final class ChatModel: ObservableObject { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { // update previews if let i = getChatIndex(cInfo.id) { - chats[i].chatItems = [cItem] + chats[i].chatItems = switch cInfo { + case .group: + if let currentPreviewItem = chats[i].chatItems.first { + if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { + [cItem] + } else { + [currentPreviewItem] + } + } else { + [cItem] + } + default: + [cItem] + } if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 increaseUnreadCounter(user: currentUser!) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e010de3e8f..19030a2842 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -605,27 +605,29 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { throw r } -func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { - let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) +func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? { + let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { AlertManager.shared.showAlert(alert) return nil } else { - return connReqType + return r } } -func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) { +func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) + let m = ChatModel.shared switch r { - case .sentConfirmation: return (.invitation, nil) - case .sentInvitation: return (.contact, nil) + case let .sentConfirmation(_, connection): + return ((.invitation, connection), nil) + case let .sentInvitation(_, connection): + return ((.contact, connection), nil) case let .contactAlreadyExists(_, contact): - let m = ChatModel.shared if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } @@ -1362,18 +1364,6 @@ func processReceivedMsg(_ res: ChatResponse) async { let m = ChatModel.shared logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(user, connection): - if active(user) { - await MainActor.run { - m.updateContactConnection(connection) - } - } - case let .contactConnectionDeleted(user, connection): - if active(user) { - await MainActor.run { - m.removeChat(connection.id) - } - } case let .contactDeletedByContact(user, contact): if active(user) && contact.directOrUsed { await MainActor.run { @@ -1666,36 +1656,40 @@ func processReceivedMsg(_ res: ChatResponse) async { activateCall(invitation) case let .callOffer(_, contact, callType, offer, sharedKey, _): await withCall(contact) { call in - call.callState = .offerReceived - call.peerMedia = callType.media - call.sharedKey = sharedKey + await MainActor.run { + call.callState = .offerReceived + call.peerMedia = callType.media + call.sharedKey = sharedKey + } let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let iceServers = getIceServers() logger.debug(".callOffer useRelay \(useRelay)") logger.debug(".callOffer iceServers \(String(describing: iceServers))") - m.callCommand = .offer( + await m.callCommand.processCommand(.offer( offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, iceServers: iceServers, relay: useRelay - ) + )) } case let .callAnswer(_, contact, answer): await withCall(contact) { call in - call.callState = .answerReceived - m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) + await MainActor.run { + call.callState = .answerReceived + } + await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)) } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { CallController.shared.reportCallRemoteEnded(invitation: invitation) } await withCall(contact) { call in - m.callCommand = .end + await m.callCommand.processCommand(.end) CallController.shared.reportCallRemoteEnded(call: call) } case .chatSuspended: @@ -1753,9 +1747,9 @@ func processReceivedMsg(_ res: ChatResponse) async { logger.debug("unsupported event: \(res.responseType)") } - func withCall(_ contact: Contact, _ perform: (Call) -> Void) async { + func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async { if let call = m.activeCall, call.contact.apiId == contact.apiId { - await MainActor.run { perform(call) } + await perform(call) } else { logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ad9d90c38d..e613476a1d 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -49,10 +49,10 @@ struct ActiveCallView: View { } .onDisappear { logger.debug("ActiveCallView: disappear") + Task { await m.callCommand.setClient(nil) } AppDelegate.keepScreenOn(false) client?.endCall() } - .onChange(of: m.callCommand) { _ in sendCommandToClient()} .background(.black) .preferredColorScheme(.dark) } @@ -60,19 +60,8 @@ struct ActiveCallView: View { private func createWebRTCClient() { if client == nil && canConnectCall { client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) - sendCommandToClient() - } - } - - private func sendCommandToClient() { - if call == m.activeCall, - m.activeCall != nil, - let client = client, - let cmd = m.callCommand { - m.callCommand = nil - logger.debug("sendCallCommand: \(cmd.cmdType)") Task { - await client.sendCallCommand(command: cmd) + await m.callCommand.setClient(client) } } } @@ -168,8 +157,10 @@ struct ActiveCallView: View { } case let .error(message): logger.debug("ActiveCallView: command error: \(message)") + AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message))) case let .invalid(type): logger.debug("ActiveCallView: invalid response: \(type)") + AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type))) } } } @@ -255,7 +246,6 @@ struct ActiveCallOverlay: View { HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { -// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))") Text("(") + Text(connInfo.text) + Text(")") } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 6e3066d1a0..194af3ab01 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -22,7 +22,7 @@ class CallManager { let m = ChatModel.shared if let call = m.activeCall, call.callkitUUID == callUUID { m.showCallView = true - m.callCommand = .capabilities(media: call.localMedia) + Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) } return true } return false @@ -57,19 +57,21 @@ class CallManager { m.activeCall = call m.showCallView = true - m.callCommand = .start( + Task { + await m.callCommand.processCommand(.start( media: invitation.callType.media, aesKey: invitation.sharedKey, iceServers: iceServers, relay: useRelay - ) + )) + } } } func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { let m = ChatModel.shared - m.callCommand = .media(media: media, enable: enable) + Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) } return true } return false @@ -94,11 +96,13 @@ class CallManager { completed() } else { logger.debug("CallManager.endCall: ending call...") - m.callCommand = .end - m.activeCall = nil - m.showCallView = false - completed() Task { + await m.callCommand.processCommand(.end) + await MainActor.run { + m.activeCall = nil + m.showCallView = false + completed() + } do { try await apiEndCall(call.contact) } catch { diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ceeaf513de..c21ef5019a 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -335,6 +335,50 @@ extension WCallResponse: Encodable { } } +actor WebRTCCommandProcessor { + private var client: WebRTCClient? = nil + private var commands: [WCallCommand] = [] + private var running: Bool = false + + func setClient(_ client: WebRTCClient?) async { + logger.debug("WebRTC: setClient, commands count \(self.commands.count)") + self.client = client + if client != nil { + await processAllCommands() + } else { + commands.removeAll() + } + } + + func processCommand(_ c: WCallCommand) async { +// logger.debug("WebRTC: process command \(c.cmdType)") + commands.append(c) + if !running && client != nil { + await processAllCommands() + } + } + + func processAllCommands() async { + logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)") + if let client = client { + running = true + while let c = commands.first, shouldRunCommand(client, c) { + commands.remove(at: 0) + await client.sendCallCommand(command: c) + logger.debug("WebRTC: processed cmd \(c.cmdType)") + } + running = false + } + } + + func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool { + switch c { + case .capabilities, .start, .offer, .end: true + default: client.activeCall.wrappedValue != nil + } + } +} + struct ConnectionState: Codable, Equatable { var connectionState: String var iceConnectionState: String @@ -358,26 +402,12 @@ struct ConnectionInfo: Codable, Equatable { return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)" } } - - var protocolText: String { - let unknown = NSLocalizedString("unknown", comment: "connection info") - let local = localCandidate?.protocol?.uppercased() ?? unknown - let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown - let remote = remoteCandidate?.protocol?.uppercased() ?? unknown - let localText = localRelay == local || localCandidate?.relayProtocol == nil - ? local - : "\(local) (\(localRelay))" - return local == remote - ? localText - : "\(localText) / \(remote)" - } } // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate struct RTCIceCandidate: Codable, Equatable { var candidateType: RTCIceCandidateType? var `protocol`: String? - var relayProtocol: String? var sdpMid: String? var sdpMLineIndex: Int? var candidate: String diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 5ecec1a982..acb459938f 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -21,7 +21,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg struct Call { var connection: RTCPeerConnection - var iceCandidates: [RTCIceCandidate] + var iceCandidates: IceCandidates var localMedia: CallMediaType var localCamera: RTCVideoCapturer? var localVideoSource: RTCVideoSource? @@ -33,10 +33,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameDecryptor: RTCFrameDecryptor? } + actor IceCandidates { + private var candidates: [RTCIceCandidate] = [] + + func getAndClear() async -> [RTCIceCandidate] { + let cs = candidates + candidates = [] + return cs + } + + func append(_ c: RTCIceCandidate) async { + candidates.append(c) + } + } + private let rtcAudioSession = RTCAudioSession.sharedInstance() private let audioQueue = DispatchQueue(label: "audio") private var sendCallResponse: (WVAPIMessage) async -> Void - private var activeCall: Binding + var activeCall: Binding private var localRendererAspectRatio: Binding @available(*, unavailable) @@ -60,7 +74,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), ] - func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { + func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) connection.delegate = self createAudioSender(connection) @@ -87,7 +101,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } return Call( connection: connection, - iceCandidates: remoteIceCandidates, + iceCandidates: IceCandidates(), localMedia: mediaType, localCamera: localCamera, localVideoSource: localVideoSource, @@ -144,26 +158,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg logger.debug("starting incoming call - create webrtc session") if activeCall.wrappedValue != nil { endCall() } let encryption = WebRTCClient.enableEncryption - let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay) + let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay) activeCall.wrappedValue = call - call.connection.offer { answer in - Task { - let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 }) - if gotCandidates { - await self.sendCallResponse(.init( - corrId: nil, - resp: .offer( - offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), - iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])), - capabilities: CallCapabilities(encryption: encryption) - ), - command: command) - ) - } else { - self.endCall() - } - } - + let (offer, error) = await call.connection.offer() + if let offer = offer { + resp = .offer( + offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))), + iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())), + capabilities: CallCapabilities(encryption: encryption) + ) + self.waitForMoreIceCandidates() + } else { + resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")") } case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): if activeCall.wrappedValue != nil { @@ -172,26 +178,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "accept: encryption is not supported") } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay) + let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay) activeCall.wrappedValue = call let pc = call.connection if let type = offer.type, let sdp = offer.sdp { if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { - pc.answer { answer in + let (answer, error) = await pc.answer() + if let answer = answer { self.addIceCandidates(pc, remoteIceCandidates) -// Task { -// try? await Task.sleep(nanoseconds: 32_000 * 1000000) - Task { - await self.sendCallResponse(.init( - corrId: nil, - resp: .answer( - answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), - iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates)) - ), - command: command) - ) - } -// } + resp = .answer( + answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), + iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())) + ) + self.waitForMoreIceCandidates() + } else { + resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")") } } else { resp = .error(message: "accept: remote description is not set") @@ -234,6 +235,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .ok } case .end: + // TODO possibly, endCall should be called before returning .ok await sendCallResponse(.init(corrId: nil, resp: .ok, command: command)) endCall() } @@ -242,6 +244,33 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + func getInitialIceCandidates() async -> [RTCIceCandidate] { + await untilIceComplete(timeoutMs: 750, stepMs: 150) {} + let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? [] + logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)") + return candidates + } + + func waitForMoreIceCandidates() { + Task { + await untilIceComplete(timeoutMs: 12000, stepMs: 1500) { + let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? [] + if candidates.count > 0 { + logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)") + await self.sendIceCandidates(candidates) + } + } + } + } + + func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { + await self.sendCallResponse(.init( + corrId: nil, + resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))), + command: nil) + ) + } + func enableMedia(_ media: CallMediaType, _ enable: Bool) { logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) @@ -387,12 +416,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg audioSessionToDefaults() } - func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool { - let startedAt = DispatchTime.now() - while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds { - guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break } - } - return success() + func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async { + var t: UInt64 = 0 + repeat { + _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) + t += stepMs + await action() + } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete } } @@ -405,25 +435,33 @@ extension WebRTC.RTCPeerConnection { optionalConstraints: nil) } - func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { - offer(for: mediaConstraints()) { (sdp, error) in - guard let sdp = sdp else { - return + func offer() async -> (RTCSessionDescription?, Error?) { + await withCheckedContinuation { cont in + offer(for: mediaConstraints()) { (sdp, error) in + self.processSDP(cont, sdp, error) } - self.setLocalDescription(sdp, completionHandler: { (error) in - completion(sdp) - }) } } - func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { - answer(for: mediaConstraints()) { (sdp, error) in - guard let sdp = sdp else { - return + func answer() async -> (RTCSessionDescription?, Error?) { + await withCheckedContinuation { cont in + answer(for: mediaConstraints()) { (sdp, error) in + self.processSDP(cont, sdp, error) } + } + } + + private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) { + if let sdp = sdp { self.setLocalDescription(sdp, completionHandler: { (error) in - completion(sdp) + if let error = error { + cont.resume(returning: (nil, error)) + } else { + cont.resume(returning: (sdp, nil)) + } }) + } else { + cont.resume(returning: (nil, error)) } } } @@ -479,6 +517,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate { default: enableSpeaker = false } setSpeakerEnabledAndConfigureSession(enableSpeaker) + case .connected: sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: do {} } @@ -491,7 +530,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { // logger.debug("Connection generated candidate \(candidate.debugDescription)") - activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil)) + Task { + await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil)) + } } func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) { @@ -506,10 +547,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate { lastReceivedMs lastDataReceivedMs: Int32, changeReason reason: String) { // logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)") - sendConnectedEvent(connection, local: local, remote: remote) } - func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) { + func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) { connection.statistics { (stats: RTCStatisticsReport) in stats.statistics.values.forEach { stat in // logger.debug("Stat \(stat.debugDescription)") @@ -517,24 +557,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate { let localId = stat.values["localCandidateId"] as? String, let remoteId = stat.values["remoteCandidateId"] as? String, let localStats = stats.statistics[localId], - let remoteStats = stats.statistics[remoteId], - local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") && - remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))") + let remoteStats = stats.statistics[remoteId] { Task { await self.sendCallResponse(.init( corrId: nil, resp: .connected(connectionInfo: ConnectionInfo( - localCandidate: local.toCandidate( - RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), - localStats.values["protocol"] as? String, - localStats.values["relayProtocol"] as? String + localCandidate: RTCIceCandidate( + candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), + protocol: localStats.values["protocol"] as? String, + sdpMid: nil, + sdpMLineIndex: nil, + candidate: "" ), - remoteCandidate: remote.toCandidate( - RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), - remoteStats.values["protocol"] as? String, - remoteStats.values["relayProtocol"] as? String - ))), + remoteCandidate: RTCIceCandidate( + candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), + protocol: remoteStats.values["protocol"] as? String, + sdpMid: nil, + sdpMLineIndex: nil, + candidate: ""))), command: nil) ) } @@ -634,11 +675,10 @@ extension RTCIceCandidate { } extension WebRTC.RTCIceCandidate { - func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate { + func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate { RTCIceCandidate( candidateType: candidateType, protocol: `protocol`, - relayProtocol: relayProtocol, sdpMid: sdpMid, sdpMLineIndex: Int(sdpMLineIndex), candidate: sdp diff --git a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift index 0b9cfe7a17..3be9e1c3b3 100644 --- a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift @@ -73,6 +73,7 @@ struct CreateLinkView: View { Task { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { await MainActor.run { + m.updateContactConnection(pcc) connReqInvitation = connReq contactConnection = pcc m.connReqInv = connReq diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 637c010328..170805b488 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -52,6 +52,9 @@ struct NewChatButton: View { func addContactAction() { Task { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + await MainActor.run { + ChatModel.shared.updateContactConnection(pcc) + } actionSheet = .createLink(link: connReq, connection: pcc) } } @@ -346,7 +349,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { + await MainActor.run { + ChatModel.shared.updateContactConnection(pcc) + } let crt: ConnReqType if let plan = connectionPlan { crt = planToConnReqType(plan) diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 4bec3f8e66..82c4629c0c 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -11,20 +11,12 @@ import CoreImage.CIFilterBuiltins struct MutableQRCode: View { @Binding var uri: String - @State private var image: UIImage? + var withLogo: Bool = true + var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) var body: some View { - ZStack { - if let image = image { - qrCodeImage(image) - } - } - .onAppear { - image = generateImage(uri) - } - .onChange(of: uri) { _ in - image = generateImage(uri) - } + QRCode(uri: uri, withLogo: withLogo, tintColor: tintColor) + .id("simplex-qrcode-view-for-\(uri)") } } @@ -49,7 +41,7 @@ struct QRCode: View { var withLogo: Bool = true var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) @State private var image: UIImage? = nil - @State private var makeScreenshotBinding: () -> Void = {} + @State private var makeScreenshotFunc: () -> Void = {} var body: some View { ZStack { @@ -70,18 +62,18 @@ struct QRCode: View { } } .onAppear { - makeScreenshotBinding = { + makeScreenshotFunc = { let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale) - showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])} + showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)]) + } } .frame(width: geo.size.width, height: geo.size.height) } } - .onTapGesture(perform: makeScreenshotBinding) + .onTapGesture(perform: makeScreenshotFunc) .onAppear { - image = image ?? generateImage(uri)?.replaceColor(UIColor.black, tintColor) + image = image ?? generateImage(uri, tintColor: tintColor) } - } } @@ -93,13 +85,13 @@ private func qrCodeImage(_ image: UIImage) -> some View { .textSelection(.enabled) } -private func generateImage(_ uri: String) -> UIImage? { +private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(uri.utf8) if let outputImage = filter.outputImage, let cgImage = context.createCGImage(outputImage, from: outputImage.extent) { - return UIImage(cgImage: cgImage) + return UIImage(cgImage: cgImage).replaceColor(UIColor.black, tintColor) } return nil } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 60e5cc7b04..e9657961ef 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -190,7 +190,8 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) + SimpleXLinkQRCode(uri: userAddress.connReqContact) + .id("simplex-contact-address-qrcode-\(userAddress.connReqContact)") shareQRCodeButton(userAddress) if MFMailComposeViewController.canSendMail() { shareViaEmailButton(userAddress) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8612370b00..1ddff81f99 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,6 +43,11 @@ 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 */; }; + 5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B32B1E7D75007981AA /* libgmp.a */; }; + 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */; }; + 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */; }; + 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B62B1E7D75007981AA /* libffi.a */; }; + 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -120,11 +125,6 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; - 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9B2B120ADF00C510B1 /* libgmp.a */; }; - 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */; }; - 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */; }; - 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */; }; - 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD67B9F2B120ADF00C510B1 /* libffi.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, ); }; }; @@ -295,6 +295,11 @@ 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 = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; + 5C4BB4B32B1E7D75007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a"; sourceTree = ""; }; + 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a"; sourceTree = ""; }; + 5C4BB4B62B1E7D75007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -408,11 +413,6 @@ 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; - 5CD67B9B2B120ADF00C510B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a"; sourceTree = ""; }; - 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a"; sourceTree = ""; }; - 5CD67B9F2B120ADF00C510B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.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 = ""; }; @@ -521,13 +521,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CD67BA12B120ADF00C510B1 /* libgmpxx.a in Frameworks */, - 5CD67BA22B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CD67BA02B120ADF00C510B1 /* libgmp.a in Frameworks */, - 5CD67BA42B120ADF00C510B1 /* libffi.a in Frameworks */, - 5CD67BA32B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a in Frameworks */, + 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */, + 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */, + 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */, + 5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,11 +589,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CD67B9F2B120ADF00C510B1 /* libffi.a */, - 5CD67B9B2B120ADF00C510B1 /* libgmp.a */, - 5CD67B9C2B120ADF00C510B1 /* libgmpxx.a */, - 5CD67B9E2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u-ghc8.10.7.a */, - 5CD67B9D2B120ADF00C510B1 /* libHSsimplex-chat-5.4.0.6-9DfazyElTA72omjHp0C93u.a */, + 5C4BB4B62B1E7D75007981AA /* libffi.a */, + 5C4BB4B32B1E7D75007981AA /* libgmp.a */, + 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */, + 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */, + 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ad0e5ee100..3d2c21392e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error { case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) - case sentConfirmation(user: UserRef) - case sentInvitation(user: UserRef) + case sentConfirmation(user: UserRef, connection: PendingContactConnection) + case sentInvitation(user: UserRef, connection: PendingContactConnection) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) @@ -605,7 +605,6 @@ public enum ChatResponse: Decodable, Error { case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) 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]) @@ -613,7 +612,7 @@ public enum ChatResponse: Decodable, Error { case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) - case remoteCtrlStopped + case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) // misc case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case cmdOk(user: UserRef?) @@ -752,7 +751,6 @@ public enum ChatResponse: Decodable, Error { case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" - case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" @@ -803,11 +801,11 @@ public enum ChatResponse: Decodable, Error { case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) + case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) - case .sentConfirmation: return noDetails - case .sentInvitation: return noDetails + case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) + case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) @@ -900,7 +898,6 @@ public enum ChatResponse: Decodable, Error { case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" 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, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" @@ -1552,6 +1549,13 @@ public enum RemoteCtrlSessionState: Decodable { case connected(sessionCode: String) } +public enum RemoteCtrlStopReason: Decodable { + case discoveryFailed(chatError: ChatError) + case connectionFailed(chatError: ChatError) + case setupFailed(chatError: ChatError) + case disconnected +} + public struct CtrlAppInfo: Decodable { public var appVersionRange: AppVersionRange public var deviceName: String diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index a35d3f5195..a9909d2f6f 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -12,7 +12,7 @@ android { defaultConfig { applicationId = "chat.simplex.app" - minSdkVersion(26) + minSdkVersion(28) targetSdkVersion(33) // !!! // skip version code after release to F-Droid, as it uses two version codes 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 a84590fb85..cbe0ef7b16 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 @@ -41,9 +41,7 @@ class MainActivity: FragmentActivity() { ) } setContent { - SimpleXTheme { - AppScreen() - } + AppScreen() } SimplexApp.context.schedulePeriodicServiceRestartWorker() SimplexApp.context.schedulePeriodicWakeUp() 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 b6afab4eaa..a345e6e48f 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 @@ -32,7 +32,9 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onCreate() { super.onCreate() if (ProcessPhoenix.isPhoenixProcess(this)) { - return; + return + } else { + registerGlobalErrorHandler() } context = this initHaskell() @@ -75,7 +77,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) { SimplexService.showBackgroundServiceNoticeIfNeeded() } /** diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 55e03f6209..4b0e38d8a0 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -110,7 +110,7 @@ android { compileSdkVersion(34) sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { - minSdkVersion(26) + minSdkVersion(28) targetSdkVersion(33) } compileOptions { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index ca497cbc5b..96bb739113 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -8,10 +8,14 @@ import android.os.Build import android.view.* import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.activity.compose.setContent import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalView -import chat.simplex.common.views.helpers.KeyboardState +import chat.simplex.common.AppScreen +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 +import chat.simplex.res.MR actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() @@ -71,3 +75,37 @@ actual fun hideKeyboard(view: Any?) { } actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true) + +actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { + actual override fun uncaughtException(thread: Thread, e: Throwable) { + Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString()) + if (ModalManager.start.hasModalsOpen()) { + ModalManager.start.closeModal() + } else if (chatModel.chatId.value != null) { + // Since no modals are open, the problem is probably in ChatView + chatModel.chatId.value = null + chatModel.chatItems.clear() + } else { + // ChatList, nothing to do. Maybe to show other view except ChatList + } + chatModel.activeCall.value?.let { + withBGApi { + chatModel.callManager.endCall(it) + } + } + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_was_crashed), + text = e.stackTraceToString() + ) + //mainActivity.get()?.recreate() + mainActivity.get()?.apply { + window + ?.decorView + ?.findViewById(android.R.id.content) + ?.removeViewAt(0) + setContent { + AppScreen() + } + } + } +} 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 51c3623250..5f30d21bb9 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 @@ -370,7 +370,6 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { InfoText(call.callState.text) val connInfo = call.connectionInfo - // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" InfoText(call.encryptionStatus + connInfoText) } @@ -585,8 +584,8 @@ fun PreviewActiveCallOverlayVideo() { localMedia = CallMediaType.Video, peerMedia = CallMediaType.Video, connectionInfo = ConnectionInfo( - RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null), - RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null) + RTCIceCandidate(RTCIceCandidateType.Host, "tcp"), + RTCIceCandidate(RTCIceCandidateType.Host, "tcp") ) ), speakerCanBeEnabled = true, @@ -611,8 +610,8 @@ fun PreviewActiveCallOverlayAudio() { localMedia = CallMediaType.Audio, peerMedia = CallMediaType.Audio, connectionInfo = ConnectionInfo( - RTCIceCandidate(RTCIceCandidateType.Host, "udp", null), - RTCIceCandidate(RTCIceCandidateType.Host, "udp", null) + RTCIceCandidate(RTCIceCandidateType.Host, "udp"), + RTCIceCandidate(RTCIceCandidateType.Host, "udp") ) ), speakerCanBeEnabled = true, diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt new file mode 100644 index 0000000000..bfe87b17d7 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -0,0 +1,15 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.User +import chat.simplex.res.MR + +@Composable +actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { + if (user == null) { + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick = onclick) + } else { + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick) + } +} 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 cfbcb7aa4d..4387adf95e 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 @@ -37,15 +37,16 @@ import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, - val scaffoldState: ScaffoldState, - val switchingUsersAndHosts: MutableState + val scaffoldState: ScaffoldState ) @Composable fun AppScreen() { - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Surface(color = MaterialTheme.colors.background) { - MainScreen() + SimpleXTheme { + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Surface(color = MaterialTheme.colors.background) { + MainScreen() + } } } } @@ -102,11 +103,8 @@ fun MainScreen() { } Box { - var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } - LaunchedEffect(Unit) { - snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it } - } - val userCreated = chatModel.userCreated.value + val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state } + val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() @@ -115,14 +113,18 @@ fun MainScreen() { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) } } - remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() - onboarding == OnboardingStage.OnboardingComplete && userCreated -> { + remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView() + onboarding == OnboardingStage.OnboardingComplete -> { Box { showAdvertiseLAAlert = true - val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(if (chatModel.desktopNoUserNoRemote()) AnimatedViewState.VISIBLE else AnimatedViewState.GONE)) } + KeyChangeEffect(chatModel.desktopNoUserNoRemote) { + if (chatModel.desktopNoUserNoRemote() && !ModalManager.start.hasModalsOpen()) { + userPickerState.value = AnimatedViewState.VISIBLE + } + } val scaffoldState = rememberScaffoldState() - val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } - val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) } + val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) } if (appPlatform.isAndroid) { AndroidScreen(settingsState) } else { @@ -137,12 +139,14 @@ fun MainScreen() { } } onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} + onboarding == OnboardingStage.LinkAMobile -> LinkAMobile() onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } if (appPlatform.isAndroid) { ModalManager.fullscreen.showInView() + SwitchingUsersView() } val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } } @@ -262,7 +266,7 @@ fun CenterPartOfScreen() { .background(MaterialTheme.colors.background), contentAlignment = Alignment.Center ) { - Text(stringResource(MR.strings.no_selected_chat)) + Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat)) } } else { ModalManager.center.showInView() @@ -286,6 +290,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { } Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) { ModalManager.start.showInView() + SwitchingUsersView() } Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) { Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { @@ -298,7 +303,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { EndPartOfScreen() } } - val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState + val (userPickerState, scaffoldState ) = settingsState val scope = rememberCoroutineScope() if (scaffoldState.drawerState.isOpen) { Box( @@ -312,7 +317,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { + UserPicker(chatModel, userPickerState) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } userPickerState.value = AnimatedViewState.GONE } @@ -335,3 +340,26 @@ fun InitializationView() { } } } + +@Composable +private fun SwitchingUsersView() { + if (remember { chatModel.switchingUsersAndHosts }.value) { + Box( + Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), + contentAlignment = Alignment.Center + ) { + ProgressIndicator() + } + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) +} 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 76c2f39fb5..25c87d64ad 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 @@ -2,7 +2,6 @@ package chat.simplex.common.model import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -43,7 +42,7 @@ object ChatModel { val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf(null) val users = mutableStateListOf() - val userCreated = mutableStateOf(null) + val localUserCreated = mutableStateOf(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) val chatDbEncrypted = mutableStateOf(false) @@ -51,6 +50,7 @@ object ChatModel { val chats = mutableStateListOf() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() + val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) @@ -67,6 +67,9 @@ object ChatModel { // set when app opened from external intent val clearOverlays = mutableStateOf(false) + // Only needed during onboarding when user skipped password setup (left as random password) + val desktopOnboardingRandomPassword = mutableStateOf(false) + // set when app is opened via contact or invitation URI val appOpenUrl = mutableStateOf(null) @@ -108,6 +111,9 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null + fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null + // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) @@ -222,8 +228,23 @@ object ChatModel { val chat: Chat if (i >= 0) { chat = chats[i] + val newPreviewItem = when (cInfo) { + is ChatInfo.Group -> { + val currentPreviewItem = chat.chatItems.firstOrNull() + if (currentPreviewItem != null) { + if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { + cItem + } else { + currentPreviewItem + } + } else { + cItem + } + } + else -> cItem + } chats[i] = chat.copy( - chatItems = arrayListOf(cItem), + chatItems = arrayListOf(newPreviewItem), chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId @@ -605,6 +626,7 @@ object ChatModel { terminalItems.add(item) } + val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } @@ -2945,6 +2967,14 @@ sealed class RemoteCtrlSessionState { @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState() } +@Serializable +sealed class RemoteCtrlStopReason { + @Serializable @SerialName("discoveryFailed") class DiscoveryFailed(val chatError: ChatError): RemoteCtrlStopReason() + @Serializable @SerialName("connectionFailed") class ConnectionFailed(val chatError: ChatError): RemoteCtrlStopReason() + @Serializable @SerialName("setupFailed") class SetupFailed(val chatError: ChatError): RemoteCtrlStopReason() + @Serializable @SerialName("disconnected") object Disconnected: RemoteCtrlStopReason() +} + sealed class UIRemoteCtrlSessionState { object Starting: UIRemoteCtrlSessionState() object Searching: 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 727e0d2a37..6c01aff5d3 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 @@ -173,6 +173,8 @@ class AppPreferences { val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) + val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( get = fun() = settings.getInt(prefName, default), @@ -317,6 +319,7 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast" private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" + private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" } } @@ -359,7 +362,7 @@ object ChatController { chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user - chatModel.userCreated.value = true + chatModel.localUserCreated.value = true getUserChatData(null) appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -379,6 +382,31 @@ object ChatController { } } + suspend fun startChatWithoutUser() { + Log.d(TAG, "user: null") + try { + if (chatModel.chatRunning.value == true) return + apiSetTempFolder(coreTmpDir.absolutePath) + apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } + apiSetXFTPConfig(getXFTPCfg()) + apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) + chatModel.users.clear() + chatModel.currentUser.value = null + chatModel.localUserCreated.value = false + appPrefs.chatLastStart.set(Clock.System.now()) + chatModel.chatRunning.value = true + startReceiver() + setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!) + Log.d(TAG, "startChat: started without user") + } catch (e: Error) { + Log.e(TAG, "failed starting chat without user $e") + throw e + } + } + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { changeActiveUser_(rhId, toUserId, viewPwd) @@ -402,8 +430,9 @@ object ChatController { } suspend fun getUserChatData(rhId: Long?) { - chatModel.userAddress.value = apiGetUserAddress(rhId) - chatModel.chatItemTTL.value = getChatItemTTL(rhId) + val hasUser = chatModel.currentUser.value != null + chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null + chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None updatingChatsMutex.withLock { val chats = apiGetChats(rhId) chatModel.updateChats(chats) @@ -472,7 +501,9 @@ object ChatController { val r = sendCmd(rh, CC.ShowActiveUser()) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") - chatModel.userCreated.value = false + if (rh == null) { + chatModel.localUserCreated.value = false + } return null } @@ -891,20 +922,21 @@ object ChatController { return null } - suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean { + suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") - return false + return null } val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) when { - r is CR.SentConfirmation || r is CR.SentInvitation -> return true + r is CR.SentConfirmation -> return r.connection + r is CR.SentInvitation -> return r.connection r is CR.ContactAlreadyExists -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) - return false + return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { @@ -912,7 +944,7 @@ object ChatController { generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) ) - return false + return null } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP @@ -921,13 +953,13 @@ object ChatController { generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth_desc) ) - return false + return null } else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) } - return false + return null } } } @@ -1394,9 +1426,9 @@ object ChatController { chatModel.remoteHosts.addAll(hosts) } - suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true): Triple? { - val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast)) - if (r is CR.RemoteHostStarted) return Triple(r.remoteHost_, r.invitation, r.ctrlPort) + suspend fun startRemoteHost(rhId: Long?, multicast: Boolean = true, address: RemoteCtrlAddress?, port: Int?): CR.RemoteHostStarted? { + val r = sendCmd(null, CC.StartRemoteHost(rhId, multicast, address, port)) + if (r is CR.RemoteHostStarted) return r apiErrorAlert("startRemoteHost", generalGetString(MR.strings.error_alert_title), r) return null } @@ -1526,16 +1558,6 @@ object ChatController { 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(rhId, r.connection) - } - } - is CR.ContactConnectionDeleted -> { - if (active(r.user)) { - chatModel.removeChat(rhId, r.connection.id) - } - } is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(rhId, r.contact) @@ -1996,7 +2018,7 @@ object ChatController { chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } - suspend fun switchUIRemoteHost(rhId: Long?) { + suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null ModalManager.center.closeModals() @@ -2009,7 +2031,10 @@ object ChatController { chatModel.users.clear() chatModel.users.addAll(users) chatModel.currentUser.value = user - chatModel.userCreated.value = true + if (user == null) { + chatModel.chatItems.clear() + chatModel.chats.clear() + } val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { chatModel.networkStatuses.clear() @@ -2019,6 +2044,23 @@ object ChatController { getUserChatData(rhId) } + suspend fun showProgressIfNeeded(block: suspend () -> Unit) { + val job = withBGApi { + try { + delay(500) + chatModel.switchingUsersAndHosts.value = true + } catch (e: Throwable) { + chatModel.switchingUsersAndHosts.value = false + } + } + try { + block() + } finally { + job.cancel() + chatModel.switchingUsersAndHosts.value = false + } + } + fun getXFTPCfg(): XFTPFileConfig { return XFTPFileConfig(minFileSize = 0) } @@ -2206,7 +2248,7 @@ sealed class CC { // Remote control class SetLocalDeviceName(val displayName: String): CC() class ListRemoteHosts(): CC() - class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean): CC() + class StartRemoteHost(val remoteHostId: Long?, val multicast: Boolean, val address: RemoteCtrlAddress?, val port: Int?): CC() class SwitchRemoteHost (val remoteHostId: Long?): CC() class StopRemoteHost(val remoteHostKey: Long?): CC() class DeleteRemoteHost(val remoteHostId: Long): CC() @@ -2342,7 +2384,7 @@ sealed class CC { 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 StartRemoteHost -> "/start remote host " + (if (remoteHostId == null) "new" else "$remoteHostId multicast=${onOff(multicast)}") + (if (address != null) " addr=${address.address} iface=${address.`interface`}" else "") + (if (port != null) " port=$port" else "") 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" @@ -3564,6 +3606,8 @@ data class RemoteHostInfo( val remoteHostId: Long, val hostDeviceName: String, val storePath: String, + val bindAddress_: RemoteCtrlAddress?, + val bindPort_: Int?, val sessionState: RemoteHostSessionState? ) { val activeHost: Boolean @@ -3572,6 +3616,12 @@ data class RemoteHostInfo( fun activeHost(): Boolean = chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId } +@Serializable +data class RemoteCtrlAddress( + val address: String, + val `interface`: String +) + @Serializable sealed class RemoteHostSessionState { @Serializable @SerialName("starting") object Starting: RemoteHostSessionState() @@ -3581,6 +3631,13 @@ sealed class RemoteHostSessionState { @Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState() } +@Serializable +sealed class RemoteHostStopReason { + @Serializable @SerialName("connectionFailed") data class ConnectionFailed(val chatError: ChatError): RemoteHostStopReason() + @Serializable @SerialName("crashed") data class Crashed(val chatError: ChatError): RemoteHostStopReason() + @Serializable @SerialName("disconnected") object Disconnected: RemoteHostStopReason() +} + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -3700,8 +3757,8 @@ sealed class CR { @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() - @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() - @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() + @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() + @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @@ -3795,16 +3852,15 @@ sealed class CR { @Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR() @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): 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, val ctrlPort: String): CR() + @Serializable @SerialName("remoteHostStarted") class RemoteHostStarted(val remoteHost_: RemoteHostInfo?, val invitation: String, val localAddrs: List, val ctrlPort: String): CR() @Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR() @Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR() @Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR() - @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR() + @Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?, val rhsState: RemoteHostSessionState, val rhStopReason: RemoteHostStopReason): 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() @@ -3812,7 +3868,7 @@ sealed class 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("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): 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() @@ -3944,7 +4000,6 @@ sealed class CR { is CallAnswer -> "callAnswer" is CallExtraInfo -> "callExtraInfo" is CallEnded -> "callEnded" - is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is RemoteHostList -> "remoteHostList" is CurrentRemoteHost -> "currentRemoteHost" @@ -3999,11 +4054,11 @@ sealed class CR { is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") - is Invitation -> withUser(user, connReqInvitation) + is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) - is SentConfirmation -> withUser(user, noDetails()) - is SentInvitation -> withUser(user, noDetails()) + is SentConfirmation -> withUser(user, json.encodeToString(connection)) + is SentInvitation -> withUser(user, json.encodeToString(connection)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) @@ -4091,7 +4146,6 @@ sealed class CR { is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") 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) 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 3d3a91cb32..a4c1c333e5 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 @@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) if (user == null) { - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = null chatModel.users.clear() + if (appPlatform.isDesktop) { + /** + * Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start + * because of default value of [OnboardingStage.OnboardingComplete] + * */ + chatModel.localUserCreated.value = null + if (chatController.listRemoteHosts()?.isEmpty() == true) { + chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } + chatController.startChatWithoutUser() + } else { + chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } } else { val savedOnboardingStage = appPreferences.onboardingStage.get() appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { 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 06925e28a1..6ca065086f 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 @@ -59,7 +59,9 @@ abstract class NtfManager { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? - chatModel.controller.changeActiveUser(null, userId, null) + chatModel.controller.showProgressIfNeeded { + chatModel.controller.changeActiveUser(null, userId, null) + } } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true @@ -72,7 +74,9 @@ abstract class NtfManager { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? - chatModel.controller.changeActiveUser(null, userId, null) + chatModel.controller.showProgressIfNeeded { + 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/platform/UI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt index c61d8564c4..28ac357c2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt @@ -16,3 +16,11 @@ expect fun getKeyboardState(): State expect fun hideKeyboard(view: Any?) expect fun androidIsFinishingMainActivity(): Boolean + +fun registerGlobalErrorHandler() { + Thread.setDefaultUncaughtExceptionHandler(GlobalExceptionsHandler()) +} + +expect class GlobalExceptionsHandler(): Thread.UncaughtExceptionHandler { + override fun uncaughtException(thread: Thread, e: Throwable) +} 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 fa86a6291b..29d7033290 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 @@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.Profile +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { disabled = !canCreateProfile(displayName.value), textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, - click = { createProfileInProfiles(chatModel, displayName.value, close) }, + click = { + if (chatModel.localUserCreated.value == true) { + createProfileInProfiles(chatModel, displayName.value, close) + } else { + createProfileInNoProfileSetup(displayName.value, close) + } + }, ) SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device)) SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) @@ -168,6 +174,17 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { } } +fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { + withApi { + val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi + controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + chatModel.chatRunning.value = false + controller.startChat(user) + controller.switchUIRemoteHost(null) + close() + } +} + fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { val rhId = chatModel.remoteHostId() @@ -190,12 +207,12 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { - chatModel.controller.apiCreateActiveUser( + chatModel.currentUser.value = chatModel.controller.apiCreateActiveUser( null, Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { - onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) { + onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) { OnboardingStage.Step2_5_SetupDatabasePassphrase } else { OnboardingStage.Step3_CreateSimpleXAddress 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 6a357d26ff..3e79dfb4fc 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 @@ -127,18 +127,10 @@ sealed class WCallResponse { "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } } - - val protocolText: String get() { - val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown" - val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown" - val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown" - val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)" - return if (local == remote) localText else "$localText / $remote" - } } // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate -@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?) +@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?) // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer @Serializable data class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null) 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 8b0b2debca..1e5919c0b6 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 @@ -164,6 +164,12 @@ fun CIImageView( } } } + } else { + KeyChangeEffect(file) { + if (res.value == null) { + res.value = imageAndFilePath(file) + } + } } val loaded = res.value if (loaded != null) { 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 301f090336..a91e5e7b3c 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 @@ -68,14 +68,14 @@ 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, switchingUsersAndHosts ) = settingsState + val (userPickerState, scaffoldState ) = 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) }, drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { - if (searchInList.isEmpty()) { + if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) { FloatingActionButton( onClick = { if (!stopped) { @@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf ) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel, search = searchInList) - } else if (!switchingUsersAndHosts.value) { + } else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { Box(Modifier.fillMaxSize()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { OnboardingButtons(showNewChatSheet) @@ -121,19 +121,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { + UserPicker(chatModel, userPickerState) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } userPickerState.value = AnimatedViewState.GONE } } - if (switchingUsersAndHosts.value) { - Box( - Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), - contentAlignment = Alignment.Center - ) { - ProgressIndicator() - } - } } @Composable @@ -209,7 +201,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user navigationButton = { if (showSearch) { NavigationButtonBack(hideSearchOnBack) - } else if (chatModel.users.isEmpty()) { + } else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } } else { val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } @@ -304,17 +296,6 @@ private fun ToggleFilterButton() { } } -@Composable -private fun ProgressIndicator() { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) -} - @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) 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 ecd47c937a..8338d2960f 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, switchingUsersAndHosts) = settingsState + val (userPickerState, scaffoldState) = settingsState val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Scaffold( Modifier.padding(end = endPadding), @@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { + UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = { chatModel.sharedContent.value = null userPickerState.value = AnimatedViewState.GONE }) 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 46a333f61c..d87c05a913 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 @@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.remote.* +import chat.simplex.common.views.usersettings.doWithAuth import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay @@ -38,7 +40,6 @@ import kotlin.math.roundToInt fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - switchingUsersAndHosts: MutableState, showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, @@ -123,14 +124,10 @@ fun UserPicker( userPickerState.value = AnimatedViewState.HIDING if (!u.user.activeUser) { scope.launch { - val job = launch { - delay(500) - switchingUsersAndHosts.value = true + controller.showProgressIfNeeded { + ModalManager.closeAllModalsEverywhere() + chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) } - ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) - job.cancel() - switchingUsersAndHosts.value = false } } } @@ -162,13 +159,13 @@ fun UserPicker( val currentRemoteHost = remember { chatModel.currentRemoteHost }.value Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { if (remoteHosts.isNotEmpty()) { - if (currentRemoteHost == null) { + if (currentRemoteHost == null && chatModel.localUserCreated.value == true) { LocalDevicePickerItem(true) { userPickerState.value = AnimatedViewState.HIDING switchToLocalDevice() } Divider(Modifier.requiredHeight(1.dp)) - } else { + } else if (currentRemoteHost != null) { val connecting = rememberSaveable { mutableStateOf(false) } RemoteHostPickerItem(currentRemoteHost, actionButtonClick = { @@ -176,7 +173,7 @@ fun UserPicker( stopRemoteHostAndReloadHosts(currentRemoteHost, true) }) { userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) + switchToRemoteHost(currentRemoteHost, connecting) } Divider(Modifier.requiredHeight(1.dp)) } @@ -184,7 +181,7 @@ fun UserPicker( UsersView() - if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { + if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) { LocalDevicePickerItem(false) { userPickerState.value = AnimatedViewState.HIDING switchToLocalDevice() @@ -199,7 +196,7 @@ fun UserPicker( stopRemoteHostAndReloadHosts(h, false) }) { userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(h, switchingUsersAndHosts, connecting) + switchToRemoteHost(h, connecting) } Divider(Modifier.requiredHeight(1.dp)) } @@ -220,6 +217,18 @@ fun UserPicker( userPickerState.value = AnimatedViewState.GONE } Divider(Modifier.requiredHeight(1.dp)) + } else if (chatModel.desktopNoUserNoRemote) { + CreateInitialProfile { + doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { + ModalManager.center.showModalCloseable { close -> + LaunchedEffect(Unit) { + userPickerState.value = AnimatedViewState.HIDING + } + CreateProfile(chat.simplex.common.platform.chatModel, close) + } + } + } + Divider(Modifier.requiredHeight(1.dp)) } if (showSettings) { SettingsPickerItem(settingsClicked) @@ -401,6 +410,16 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) { } } +@Composable +private fun CreateInitialProfile(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.create_chat_profile) + Icon(painterResource(MR.images.ic_manage_accounts), 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) { @@ -441,21 +460,15 @@ private fun switchToLocalDevice() { } } -private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState, connecting: MutableState) { +private fun switchToRemoteHost(h: RemoteHostInfo, 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/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index bce8fdf4f1..4e5424215b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -264,7 +264,8 @@ private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onCli text, generalGetString(MR.strings.enter_passphrase), isValid = ::validKey, - keyboardActions = KeyboardActions(onDone = if (enabled) { + // Don't enable this on desktop since it interfere with key event listener + keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) { { onClick?.invoke() } } else null ), 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 a4c8dc981d..224317f949 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 @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionItemView +import SectionSpacer import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* @@ -20,6 +21,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -59,7 +61,9 @@ fun DatabaseView( val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) { - importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) + importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) { + startChat(m, chatLastStart, m.chatDbChanged) + } } } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } @@ -77,7 +81,6 @@ fun DatabaseView( m.chatDbEncrypted.value, m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, - m.controller.appPrefs.developerTools.state.value, importArchiveLauncher, chatArchiveName, chatArchiveTime, @@ -100,7 +103,13 @@ fun DatabaseView( setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, - showSettingsModal + showSettingsModal, + disconnectAllHosts = { + val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } + connected.forEachIndexed { index, h -> + controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) + } + } ) if (progressIndicator.value) { Box( @@ -129,7 +138,6 @@ fun DatabaseLayout( chatDbEncrypted: Boolean?, passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference, - developerTools: Boolean, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -144,36 +152,43 @@ fun DatabaseLayout( deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, onChatItemTTLSelected: (ChatItemTTL) -> Unit, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + disconnectAllHosts: () -> Unit, ) { val stopped = !runChat - val operationsDisabled = !stopped || progressIndicator + val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), ) { AppBarTitle(stringResource(MR.strings.your_chat_database)) - SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { - TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) - } - SectionTextFooter( - remember(currentUser?.displayName) { - buildAnnotatedString { - append(generalGetString(MR.strings.messages_section_description) + " ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(currentUser?.displayName ?: "") - } - append(".") - } + if (!chatModel.desktopNoUserNoRemote) { + SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { + TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) } - ) - - if (currentRemoteHost == null) { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.messages_section_description) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) SectionDividerSpaced(maxTopPadding = true) - + } + val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } + if (chatModel.localUserCreated.value == true) { SectionView(stringResource(MR.strings.run_chat_section)) { - RunChatSetting(runChat, stopped, startChat, stopChatAlert) + if (!toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert) } SectionTextFooter( if (stopped) { @@ -183,92 +198,96 @@ fun DatabaseLayout( } ) SectionDividerSpaced() - - SectionView(stringResource(MR.strings.chat_database_section)) { - val unencrypted = chatDbEncrypted == false - SettingsActionItem( - if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) - else painterResource(MR.images.ic_lock), - stringResource(MR.strings.database_passphrase), - click = showSettingsModal() { DatabaseEncryptionView(it) }, - iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, - disabled = operationsDisabled - ) - if (appPlatform.isDesktop && developerTools) { - SettingsActionItem( - painterResource(MR.images.ic_folder_open), - stringResource(MR.strings.open_database_folder), - ::desktopOpenDatabaseDir, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_ios_share), - stringResource(MR.strings.export_database), - click = { - if (initialRandomDBPassphrase.get()) { - exportProhibitedAlert() - } else { - exportArchive() - } - }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_download), - stringResource(MR.strings.import_database), - { withApi { importArchiveLauncher.launch("application/zip") } }, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - val chatArchiveNameVal = chatArchiveName.value - val chatArchiveTimeVal = chatArchiveTime.value - val chatLastStartVal = chatLastStart.value - if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { - val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) - SettingsActionItem( - painterResource(MR.images.ic_inventory_2), - title, - click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_delete_forever), - stringResource(MR.strings.delete_database), - deleteChatAlert, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - } - SectionDividerSpaced(maxTopPadding = true) - - SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { - val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 - SectionItemView( - deleteAppFilesAndMedia, - disabled = deleteFilesDisabled - ) { - Text( - stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all), - color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red - ) - } - } - val (count, size) = appFilesCountAndSize.value - SectionTextFooter( - if (count == 0) { - stringResource(MR.strings.no_received_app_files) - } else { - String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size)) - } - ) } + SectionView(stringResource(MR.strings.chat_database_section)) { + if (chatModel.localUserCreated.value != true && !toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + val unencrypted = chatDbEncrypted == false + SettingsActionItem( + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) + else painterResource(MR.images.ic_lock), + stringResource(MR.strings.database_passphrase), + click = showSettingsModal() { DatabaseEncryptionView(it) }, + iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, + disabled = operationsDisabled + ) + if (appPlatform.isDesktop) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_ios_share), + stringResource(MR.strings.export_database), + click = { + if (initialRandomDBPassphrase.get()) { + exportProhibitedAlert() + } else { + exportArchive() + } + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_download), + stringResource(MR.strings.import_database), + { withApi { importArchiveLauncher.launch("application/zip") } }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + val chatArchiveNameVal = chatArchiveName.value + val chatArchiveTimeVal = chatArchiveTime.value + val chatLastStartVal = chatLastStart.value + if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { + val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) + SettingsActionItem( + painterResource(MR.images.ic_inventory_2), + title, + click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_delete_forever), + stringResource(MR.strings.delete_database), + deleteChatAlert, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + } + SectionDividerSpaced(maxTopPadding = true) + + SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { + val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 + SectionItemView( + deleteAppFilesAndMedia, + disabled = deleteFilesDisabled + ) { + Text( + stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all), + color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red + ) + } + } + val (count, size) = appFilesCountAndSize.value + SectionTextFooter( + if (count == 0) { + stringResource(MR.strings.no_received_app_files) + } else { + String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size)) + } + ) SectionBottomSpacer() } } @@ -319,6 +338,7 @@ private fun TtlOptions(current: State, enabled: State, onS fun RunChatSetting( runChat: Boolean, stopped: Boolean, + enabled: Boolean, startChat: () -> Unit, stopChatAlert: () -> Unit ) { @@ -337,6 +357,7 @@ fun RunChatSetting( stopChatAlert() } }, + enabled = enabled, ) } } @@ -501,13 +522,14 @@ private fun importArchiveAlert( m: ChatModel, importedArchiveURI: URI, appFilesCountAndSize: MutableState>, - progressIndicator: MutableState + progressIndicator: MutableState, + startChat: () -> Unit, ) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.import_database_question), text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), confirmText = generalGetString(MR.strings.import_database_confirmation), - onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) }, + onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) }, destructive = true, ) } @@ -516,7 +538,8 @@ private fun importArchive( m: ChatModel, importedArchiveURI: URI, appFilesCountAndSize: MutableState>, - progressIndicator: MutableState + progressIndicator: MutableState, + startChat: () -> Unit, ) { progressIndicator.value = true val archivePath = saveArchiveFromURI(importedArchiveURI) @@ -533,6 +556,10 @@ private fun importArchive( operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database)) } + if (chatModel.localUserCreated.value == false) { + chatModel.chatRunning.value = false + startChat() + } } else { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import)) @@ -681,7 +708,6 @@ fun PreviewDatabaseLayout() { chatDbEncrypted = false, passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), - developerTools = true, importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, @@ -697,6 +723,7 @@ fun PreviewDatabaseLayout() { deleteAppFilesAndMedia = {}, showSettingsModal = { {} }, onChatItemTTLSelected = {}, + disconnectAllHosts = {}, ) } } 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 10bbae6bfc..ee9a8337a0 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 @@ -1,8 +1,11 @@ package chat.simplex.common.views.helpers import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -233,53 +236,71 @@ private fun alertTitle(title: String): (@Composable () -> Unit)? { @Composable private fun AlertContent(text: String?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - Column( - Modifier - .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) - ) { - if (appPlatform.isDesktop) { - HostDeviceTitle(hostDevice, extraPadding = extraPadding) - } else { - Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - } - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { - if (text != null) { - Text( - escapedHtmlToAnnotatedString(text, LocalDensity.current), - Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), - fontSize = 16.sp, - textAlign = TextAlign.Center, - color = MaterialTheme.colors.secondary - ) + BoxWithConstraints { + Column( + Modifier + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) + .verticalScroll(rememberScrollState()) + ) { + SelectionContainer { + Text( + escapedHtmlToAnnotatedString(text, LocalDensity.current), + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + } + } + content() } - content() } } @Composable private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - Column( - Modifier - .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) - ) { - if (appPlatform.isDesktop) { - HostDeviceTitle(hostDevice, extraPadding = extraPadding) - } else { - Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - } - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { - if (text != null) { - Text( - text, - Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), - fontSize = 16.sp, - textAlign = TextAlign.Center, - color = MaterialTheme.colors.secondary - ) + BoxWithConstraints { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) + ) { + if (appPlatform.isDesktop) { + HostDeviceTitle(hostDevice, extraPadding = extraPadding) + } else { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { + if (text != null) { + Column( + Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) + .verticalScroll(rememberScrollState()) + ) { + SelectionContainer { + Text( + text, + Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.secondary + ) + } + } + } + } + content() } - content() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index 08dfaa0dfb..a6f0d2c9b6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -19,8 +19,8 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* import chat.simplex.common.views.database.PassphraseStrength import chat.simplex.common.views.database.validKey import chat.simplex.res.MR @@ -123,6 +123,7 @@ fun DefaultConfigurableTextField( isValid: (String) -> Boolean, keyboardActions: KeyboardActions = KeyboardActions(), keyboardType: KeyboardType = KeyboardType.Text, + fontSize: TextUnit = 16.sp, dependsOn: State? = null, ) { var valid by remember { mutableStateOf(isValid(state.value.text)) } @@ -152,7 +153,6 @@ fun DefaultConfigurableTextField( BasicTextField( value = state.value, modifier = modifier - .fillMaxWidth() .background(colors.backgroundColor(enabled).value, shape) .indicatorLine(enabled, false, interactionSource, colors) .defaultMinSize( @@ -176,14 +176,14 @@ fun DefaultConfigurableTextField( textStyle = TextStyle.Default.copy( color = color, fontWeight = FontWeight.Normal, - fontSize = 16.sp + fontSize = fontSize ), interactionSource = interactionSource, decorationBox = @Composable { innerTextField -> TextFieldDefaults.TextFieldDecorationBox( value = state.value.text, innerTextField = innerTextField, - placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) }, + placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary, fontSize = fontSize, maxLines = 1, overflow = TextOverflow.Ellipsis) }, singleLine = true, enabled = enabled, isError = !valid, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 72a8aaf10a..290bc2cd07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -10,72 +10,90 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* import chat.simplex.res.MR import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +@Composable +fun ExposedDropDownSetting( + values: List>, + selection: State, + textColor: Color = MaterialTheme.colors.secondary, + fontSize: TextUnit = 16.sp, + label: String? = null, + enabled: State = mutableStateOf(true), + minWidth: Dp = 200.dp, + maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, + onSelected: (T) -> Unit +) { + val expanded = remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded.value, + onExpandedChange = { + expanded.value = !expanded.value && enabled.value + } + ) { + Row( + Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""), + Modifier.widthIn(max = maxWidth), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = textColor, + fontSize = fontSize, + ) + Spacer(Modifier.size(12.dp)) + Icon( + if (!expanded.value) painterResource(MR.images.ic_expand_more) else painterResource(MR.images.ic_expand_less), + generalGetString(MR.strings.icon_descr_more_button), + tint = MaterialTheme.colors.secondary + ) + } + DefaultExposedDropdownMenu( + modifier = Modifier.widthIn(min = minWidth), + expanded = expanded, + ) { + values.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + onSelected(selectionOption.first) + expanded.value = false + }, + contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) + ) { + Text( + selectionOption.second + (if (label != null) " $label" else ""), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, + fontSize = fontSize, + ) + } + } + } + } +} + @Composable fun ExposedDropDownSettingRow( title: String, values: List>, selection: State, + textColor: Color = MaterialTheme.colors.secondary, label: String? = null, icon: Painter? = null, iconTint: Color = MaterialTheme.colors.secondary, enabled: State = mutableStateOf(true), + minWidth: Dp = 200.dp, + maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, onSelected: (T) -> Unit ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { - val expanded = remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded.value, - onExpandedChange = { - expanded.value = !expanded.value && enabled.value - } - ) { - Row( - Modifier.padding(start = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - val maxWidth = with(LocalDensity.current) { 180.sp.toDp() } - Text( - values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""), - Modifier.widthIn(max = maxWidth), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.secondary - ) - Spacer(Modifier.size(12.dp)) - Icon( - if (!expanded.value) painterResource(MR.images.ic_expand_more) else painterResource(MR.images.ic_expand_less), - generalGetString(MR.strings.icon_descr_more_button), - tint = MaterialTheme.colors.secondary - ) - } - DefaultExposedDropdownMenu( - modifier = Modifier.widthIn(min = 200.dp), - expanded = expanded, - ) { - values.forEach { selectionOption -> - DropdownMenuItem( - onClick = { - onSelected(selectionOption.first) - expanded.value = false - }, - contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) - ) { - Text( - selectionOption.second + (if (label != null) " $label" else ""), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isInDarkTheme()) MenuTextColorDark else Color.Black, - ) - } - } - } - } + ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, onSelected = onSelected) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 16d7e88d6f..ba0edb98d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.platform.onRightClick @@ -202,13 +203,14 @@ fun SectionTextFooter(text: String) { } @Composable -fun SectionTextFooter(text: AnnotatedString) { +fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) { Text( text, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F), color = MaterialTheme.colors.secondary, lineHeight = 18.sp, - fontSize = 14.sp + fontSize = 14.sp, + textAlign = textAlign ) } 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 6e12681dd2..0a0ef17c4b 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 @@ -55,7 +55,7 @@ fun annotatedStringResource(id: StringResource): AnnotatedString { @Composable fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedString { val density = LocalDensity.current - return remember(id) { + return remember(id, args) { escapedHtmlToAnnotatedString(id.localized().format(args = args), density) } } 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 a162752716..6f3caf4674 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 @@ -108,6 +108,7 @@ private fun createInvitation( withApi { val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) if (r != null) { + m.updateContactConnection(rhId, r.second) connReqInvitation.value = r.first contactConnection.value = r.second } else { 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 4deeda3e24..2439b16c36 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 @@ -283,10 +283,11 @@ suspend fun connectViaUri( incognito: Boolean, connectionPlan: ConnectionPlan?, close: (() -> Unit)? -): Boolean { - val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) +) { + val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION - if (r) { + if (pcc != null) { + chatModel.updateContactConnection(rhId, pcc) close?.invoke() AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), @@ -299,7 +300,6 @@ suspend fun connectViaUri( hostDevice = hostDevice(rhId), ) } - return r } fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { 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 49e62fb067..092fb8bf63 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 @@ -182,6 +182,10 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) { val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { + if (appPlatform.isDesktop) { + // Make possible to use chat after going to remote device linking and returning back to local profile creation + chatModel.chatRunning.value = false + } chatModel.controller.startChat(user) } else { val users = chatModel.controller.listUsers(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt new file mode 100644 index 0000000000..4e3b70405d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -0,0 +1,92 @@ +package chat.simplex.common.views.onboarding + +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.remote.AddingMobileDevice +import chat.simplex.common.views.remote.DeviceNameField +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun LinkAMobile() { + val connecting = rememberSaveable { mutableStateOf(false) } + val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess + var deviceNameInQrCode by remember { mutableStateOf(chatModel.controller.appPrefs.deviceNameForRemoteAccess.get()) } + val staleQrCode = remember { mutableStateOf(false) } + + LinkAMobileLayout( + deviceName = remember { deviceName.state }, + connecting, + staleQrCode, + updateDeviceName = { + withBGApi { + if (it != "" && it != deviceName.get()) { + chatModel.controller.setLocalDeviceName(it) + deviceName.set(it) + staleQrCode.value = deviceName.get() != deviceNameInQrCode + } + } + } + ) + KeyChangeEffect(staleQrCode.value) { + if (!staleQrCode.value) { + deviceNameInQrCode = deviceName.get() + } + } +} + +@Composable +private fun LinkAMobileLayout( + deviceName: State, + connecting: MutableState, + staleQrCode: MutableState, + updateDeviceName: (String) -> Unit, +) { + Column(Modifier.padding(top = 20.dp)) { + AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) + Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) { + Column( + Modifier.weight(0.3f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } + SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) { + ChatModel.controller.appPrefs.offerRemoteMulticast.set(it) + } + } + } + Box(Modifier.weight(0.7f)) { + AddingMobileDevice(false, staleQrCode, connecting) { + // currentRemoteHost will be set instantly but remoteHosts may be delayed + if (chatModel.remoteHosts.isEmpty() && chatModel.currentRemoteHost.value == null) { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } else { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + } + } + } + } + SimpleButtonDecorated( + text = stringResource(MR.strings.about_simplex), + icon = painterResource(MR.images.ic_arrow_back_ios_new), + textDecoration = TextDecoration.None, + fontWeight = FontWeight.Medium + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 0a48bbd1eb..d4c63248e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, + LinkAMobile, Step2_5_SetupDatabasePassphrase, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, 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 a4ebd23de4..c117e89971 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 @@ -1,10 +1,7 @@ package chat.simplex.common.views.onboarding import SectionBottomSpacer -import SectionItemView -import SectionItemViewSpaceBetween import SectionTextFooter -import SectionView import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -15,14 +12,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.* -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* @@ -43,7 +38,11 @@ fun SetupDatabasePassphrase(m: ChatModel) { val newKey = rememberSaveable { mutableStateOf("") } val confirmNewKey = rememberSaveable { mutableStateOf("") } fun nextStep() { - m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + if (appPlatform.isAndroid || chatModel.currentUser.value != null) { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + } else { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) + } } SetupDatabasePassphraseLayout( currentKey, @@ -159,10 +158,7 @@ private fun SetupDatabasePassphraseLayout( } }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, - keyboardActions = KeyboardActions(onDone = { - if (!disabled) onClickUpdate() - defaultKeyboardAction(ImeAction.Done) - }), + keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), ) Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { @@ -176,7 +172,10 @@ private fun SetupDatabasePassphraseLayout( } Spacer(Modifier.weight(1f)) - SkipButton(progressIndicator.value, nextStep) + SkipButton(progressIndicator.value) { + chatModel.desktopOnboardingRandomPassword.value = true + nextStep() + } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index f20c4508b4..2aad2556af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -8,6 +8,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -99,26 +100,22 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour } @Composable -fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) { - if (user == null) { - OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick) - } else { - OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick) - } -} +expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) @Composable fun OnboardingActionButton( labelId: StringResource, onboarding: OnboardingStage?, border: Boolean, + icon: Painter? = null, + iconColor: Color = MaterialTheme.colors.primary, onclick: (() -> Unit)? ) { val modifier = if (border) { Modifier .border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50)) .padding( - horizontal = DEFAULT_PADDING * 2, + horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF, vertical = 4.dp ) } else { @@ -131,6 +128,9 @@ fun OnboardingActionButton( ChatController.appPrefs.onboardingStage.set(onboarding) } }, modifier) { + if (icon != null) { + Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) + } Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp) Icon( painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary, 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 index 53f0339bee..a3218c961b 100644 --- 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 @@ -9,16 +9,20 @@ import SectionView import TextIconSpaced import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.input.* +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -30,11 +34,11 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode -import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun ConnectMobileView() { @@ -97,9 +101,11 @@ fun ConnectMobileLayout( 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) + if (chatModel.localUserCreated.value == true) { + 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) + } } } @@ -152,7 +158,7 @@ fun DeviceNameField( DefaultConfigurableTextField( state = state, placeholder = generalGetString(MR.strings.enter_this_device_name), - modifier = Modifier.padding(start = DEFAULT_PADDING), + modifier = Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING), isValid = { true }, ) KeyChangeEffect(state.value) { @@ -162,26 +168,40 @@ fun DeviceNameField( @Composable private fun ConnectMobileViewLayout( - title: String, + title: String?, invitation: String?, deviceName: String?, sessionCode: String?, - port: String? + port: String?, + staleQrCode: Boolean = false, + refreshQrCode: () -> Unit = {}, + UnderQrLayout: @Composable () -> Unit = {}, ) { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - AppBarTitle(title) + if (title != null) { + AppBarTitle(title) + } SectionView { if (invitation != null && sessionCode == null && port != 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)) - SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port)) + Box { + QRCode( + invitation, Modifier + .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + if (staleQrCode) { + Box(Modifier.matchParentSize().background(MaterialTheme.colors.background.copy(alpha = 0.9f)), contentAlignment = Alignment.Center) { + SimpleButtonDecorated(stringResource(MR.strings.refresh_qr_code), painterResource(MR.images.ic_refresh), click = refreshQrCode) + } + } + } + SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code), textAlign = TextAlign.Center) + SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect), textAlign = TextAlign.Center) + + UnderQrLayout() if (remember { controller.appPrefs.developerTools.state }.value) { val clipboard = LocalClipboardManager.current @@ -218,6 +238,9 @@ private fun ConnectMobileViewLayout( } } } + if (invitation != null) { + SectionBottomSpacer() + } } } @@ -237,64 +260,105 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> - val invitation = rememberSaveable { mutableStateOf(null) } - val port = rememberSaveable { mutableStateOf(null) } - val pairing = remember { chatModel.remoteHostPairing } - 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, - port = port.value + AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close) + } +} + +@Composable +fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState, connecting: MutableState, close: () -> Unit) { + var cachedR by remember { mutableStateOf(null) } + val customAddress = rememberSaveable { mutableStateOf(null) } + val customPort = rememberSaveable { mutableStateOf(null) } + val startRemoteHost = suspend { + val r = chatModel.controller.startRemoteHost( + rhId = null, + multicast = controller.appPrefs.offerRemoteMulticast.get(), + address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_, + port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ ) - 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() - } + if (r != null) { + cachedR = r + connecting.value = true + customAddress.value = cachedR.addresses.firstOrNull() + customPort.value = cachedR.port + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } - KeyChangeEffect(pairing.value) { - if (pairing.value == null) { - close() - } - } - DisposableEffect(Unit) { + } + val pairing = remember { chatModel.remoteHostPairing } + 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 (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), + invitation = cachedR?.invitation, + deviceName = remoteDeviceName, + sessionCode = cachedSessionCode, + port = cachedR?.ctrlPort, + staleQrCode = staleQrCode.value || (cachedR.address != customAddress.value && customAddress.value != null) || cachedR.port != customPort.value, + refreshQrCode = { withBGApi { - val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) - if (r != null) { - connecting.value = true - invitation.value = r.second - port.value = r.third - chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting + if (chatController.stopRemoteHost(null)) { + startRemoteHost() + staleQrCode.value = false } } - onDispose { - if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { - withBGApi { - chatController.stopRemoteHost(null) - } + }, + UnderQrLayout = { UnderQrLayout(cachedR, customAddress, customPort) } + ) + 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 { + startRemoteHost() + } + onDispose { + if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { + withBGApi { + chatController.stopRemoteHost(null) } - chatModel.remoteHostPairing.value = null } + chatModel.remoteHostPairing.value = null } } } private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { ModalManager.start.showModalCloseable { close -> + var cachedR by remember { mutableStateOf(null) } + val customAddress = rememberSaveable { mutableStateOf(null) } + val customPort = rememberSaveable { mutableStateOf(null) } + val startRemoteHost = suspend { + val r = chatModel.controller.startRemoteHost( + rhId = rh.remoteHostId, + multicast = controller.appPrefs.offerRemoteMulticast.get(), + address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_, + port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_ + ) + if (r != null) { + cachedR = r + connecting.value = true + customAddress.value = cachedR.addresses.firstOrNull() + customPort.value = cachedR.port + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting + } + } val pairing = remember { chatModel.remoteHostPairing } - val invitation = rememberSaveable { mutableStateOf(null) } - val port = rememberSaveable { mutableStateOf(null) } val sessionCode = when (val state = pairing.value?.second) { is RemoteHostSessionState.PendingConfirmation -> state.sessionCode else -> null @@ -306,25 +370,22 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState } ConnectMobileViewLayout( title = if (cachedSessionCode == null) stringResource(MR.strings.scan_from_mobile) else stringResource(MR.strings.verify_connection), - invitation = invitation.value, + invitation = cachedR?.invitation, deviceName = pairing.value?.first?.hostDeviceName ?: rh.hostDeviceName, sessionCode = cachedSessionCode, - port = port.value + port = cachedR?.ctrlPort, + staleQrCode = (cachedR.address != customAddress.value && customAddress.value != null) || cachedR.port != customPort.value, + refreshQrCode = { + withBGApi { + if (chatController.stopRemoteHost(rh.remoteHostId)) { + startRemoteHost() + } + } + }, + UnderQrLayout = { UnderQrLayout(cachedR, customAddress, customPort) } ) - var remoteHostId by rememberSaveable { mutableStateOf(null) } - LaunchedEffect(Unit) { - val r = chatModel.controller.startRemoteHost(rh.remoteHostId, controller.appPrefs.offerRemoteMulticast.get()) - if (r != null) { - val (rh_, inv) = r - connecting.value = true - remoteHostId = rh_?.remoteHostId - invitation.value = inv - port.value = r.third - chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting - } - } LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { - if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == remoteHostId) { + if (cachedR.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId == cachedR.remoteHostId) { close() } } @@ -334,10 +395,13 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState } } DisposableEffect(Unit) { + withBGApi { + startRemoteHost() + } onDispose { - if (remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != remoteHostId) { + if (cachedR.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != cachedR.remoteHostId) { withBGApi { - chatController.stopRemoteHost(remoteHostId) + chatController.stopRemoteHost(cachedR.remoteHostId) } } chatModel.remoteHostPairing.value = null @@ -370,3 +434,77 @@ private fun showConnectedMobileDevice(rh: RemoteHostInfo, disconnectHost: () -> } } } + +@Composable +private fun UnderQrLayout(cachedR: CR.RemoteHostStarted?, customAddress: MutableState, customPort: MutableState) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + if (cachedR.addresses.size > 1) { + ExposedDropDownSetting( + cachedR.addresses.map { it to it.address + " (${it.`interface`})" }, + customAddress, + textColor = MaterialTheme.colors.onBackground, + fontSize = 14.sp, + minWidth = 250.dp, + maxWidth = with(LocalDensity.current) { 250.sp.toDp() }, + enabled = remember { mutableStateOf(cachedR.addresses.size > 1) }, + onSelected = { + customAddress.value = it + } + ) + } else { + Spacer(Modifier.width(10.dp)) + Text(customAddress.value?.address + " (${customAddress.value?.`interface`})", fontSize = 14.sp, color = MaterialTheme.colors.onBackground) + } + val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue((customPort.value ?: cachedR.port!!).toString())) + } + Spacer(Modifier.width(DEFAULT_PADDING)) + Box { + DefaultConfigurableTextField( + portUnsaved, + stringResource(MR.strings.random_port), + modifier = Modifier.widthIn(max = 132.dp), + isValid = { (validPort(it) && it.toInt() > 1023) || it.isBlank() }, + keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), + keyboardType = KeyboardType.Number, + fontSize = 14.sp, + ) + if (validPort(portUnsaved.value.text) && portUnsaved.value.text.toInt() > 1023) { + Icon(painterResource(MR.images.ic_edit), stringResource(MR.strings.edit_verb), Modifier.padding(end = 56.dp).size(16.dp).align(Alignment.CenterEnd), tint = MaterialTheme.colors.secondary) + IconButton(::showOpenPortAlert, Modifier.align(Alignment.TopEnd).padding(top = 2.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + } + } + } + LaunchedEffect(Unit) { + snapshotFlow { portUnsaved.value.text } + .distinctUntilChanged() + .collect { + if (validPort(it) && it.toInt() > 1023) { + customPort.value = it.toInt() + } else { + customPort.value = null + } + } + } + KeyChangeEffect(customPort.value) { + if (customPort.value != null) { + portUnsaved.value = portUnsaved.value.copy(text = customPort.value.toString()) + } + } + } +} + +private fun showOpenPortAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.open_port_in_firewall_title), + text = generalGetString(MR.strings.open_port_in_firewall_desc), + ) +} + +private val CR.RemoteHostStarted?.rh: RemoteHostInfo? get() = this?.remoteHost_ +private val CR.RemoteHostStarted?.remoteHostId: Long? get() = this?.remoteHost_?.remoteHostId +private val CR.RemoteHostStarted?.address: RemoteCtrlAddress? get() = this?.localAddrs?.firstOrNull() +private val CR.RemoteHostStarted?.addresses: List get() = + (if (controller.appPrefs.developerTools.get() || this?.localAddrs?.indexOfFirst { it.address == "127.0.0.1" } == 0) this?.localAddrs else this?.localAddrs?.filterNot { it.address == "127.0.0.1" }) ?: emptyList() +private val CR.RemoteHostStarted?.port: Int? get() = this?.ctrlPort?.toIntOrNull() 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 ad30b57c6e..fcd602ee2c 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ClickableText import chat.simplex.common.views.helpers.* @@ -169,18 +170,20 @@ fun NetworkAndServersView( verticalArrangement = Arrangement.spacedBy(8.dp) ) { 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, m.remoteHostId, ServerProtocol.SMP, close) }) + if (!chatModel.desktopNoUserNoRemote) { + SectionView(generalGetString(MR.strings.settings_section_title_messages)) { + 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, m.remoteHostId, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) - if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) - UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) - if (developerTools) { - SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + if (currentRemoteHost == null) { + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) + UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) + if (developerTools) { + SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + } + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } } if (currentRemoteHost == null && networkUseSocksProxy.value) { @@ -192,7 +195,7 @@ fun NetworkAndServersView( } } Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) - } else { + } else if (!chatModel.desktopNoUserNoRemote) { Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) } @@ -302,7 +305,7 @@ fun SockProxySettings(m: ChatModel) { DefaultConfigurableTextField( hostUnsaved, stringResource(MR.strings.host_verb), - modifier = Modifier, + modifier = Modifier.fillMaxWidth(), isValid = ::validHost, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), keyboardType = KeyboardType.Text, @@ -312,7 +315,7 @@ fun SockProxySettings(m: ChatModel) { DefaultConfigurableTextField( portUnsaved, stringResource(MR.strings.port_verb), - modifier = Modifier, + modifier = Modifier.fillMaxWidth(), isValid = ::validPort, keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save() }), keyboardType = KeyboardType.Number, @@ -425,7 +428,7 @@ private fun validHost(s: String): Boolean { } // https://ihateregex.io/expr/port/ -private fun validPort(s: String): Boolean { +fun validPort(s: String): Boolean { val validPort = Regex("^(6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4})$") return s.isNotBlank() && s.matches(validPort) } 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 1a5aa49eb0..3d2b7b7fa5 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 @@ -92,7 +92,6 @@ fun PrivacySettingsView( chatModel.simplexLinkMode.value = it }) } - SectionDividerSpaced() val currentUser = chatModel.currentUser.value if (currentUser != null) { @@ -142,39 +141,42 @@ fun PrivacySettingsView( } } - DeliveryReceiptsSection( - currentUser = currentUser, - setOrAskSendReceiptsContacts = { enable -> - val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Direct) { - val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + if (!chatModel.desktopNoUserNoRemote) { + SectionDividerSpaced() + DeliveryReceiptsSection( + currentUser = currentUser, + setOrAskSendReceiptsContacts = { enable -> + val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Direct) { + val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (contactReceiptsOverrides == 0) { + setSendReceiptsContacts(enable, clearOverrides = false) } else { - count + showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) + } + }, + setOrAskSendReceiptsGroups = { enable -> + val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Group) { + val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (groupReceiptsOverrides == 0) { + setSendReceiptsGroups(enable, clearOverrides = false) + } else { + showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) } } - if (contactReceiptsOverrides == 0) { - setSendReceiptsContacts(enable, clearOverrides = false) - } else { - showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) - } - }, - setOrAskSendReceiptsGroups = { enable -> - val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Group) { - val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (groupReceiptsOverrides == 0) { - setSendReceiptsGroups(enable, clearOverrides = false) - } else { - showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) - } - } - ) + ) + } } SectionBottomSpacer() } 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 dfe3581521..0f37fb1979 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 @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo @@ -38,76 +39,39 @@ import kotlinx.coroutines.launch fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false - - if (user != null) { - val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } - SettingsLayout( - profile = user.profile, - stopped, - chatModel.chatDbEncrypted.value == true, - remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, - remember { chatModel.controller.appPrefs.notificationsMode.state }, - user.displayName, - setPerformLA = setPerformLA, - showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, - showSettingsModalWithSearch = { modalView -> - ModalManager.start.showCustomModal { close -> - val search = rememberSaveable { mutableStateOf("") } - ModalView( - { close() }, - endButtons = { - SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } - }, - content = { modalView(chatModel, search) }) + SettingsLayout( + profile = user?.profile, + stopped, + chatModel.chatDbEncrypted.value == true, + remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, + remember { chatModel.controller.appPrefs.notificationsMode.state }, + user?.displayName, + setPerformLA = setPerformLA, + showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, + showSettingsModalWithSearch = { modalView -> + ModalManager.start.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } + }, + content = { modalView(chatModel, search) }) + } + }, + showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, + showVersion = { + withApi { + val info = chatModel.controller.apiGetVersion() + if (info != null) { + ModalManager.start.showModal { VersionInfoView(info) } } - }, - showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, - showVersion = { - withApi { - val info = chatModel.controller.apiGetVersion() - if (info != null) { - ModalManager.start.showModal { VersionInfoView(info) } - } - } - }, - withAuth = { title, desc, block -> - if (!requireAuth.value) { - block() - } else { - var autoShow = true - ModalManager.fullscreen.showModalCloseable { close -> - val onFinishAuth = { success: Boolean -> - if (success) { - close() - block() - } - } - - LaunchedEffect(Unit) { - if (autoShow) { - autoShow = false - runAuth(title, desc, onFinishAuth) - } - } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) - } - } - } - }, - drawerState = drawerState, - ) - } + } + }, + withAuth = ::doWithAuth, + drawerState = drawerState, + ) } val simplexTeamUri = @@ -115,12 +79,12 @@ val simplexTeamUri = @Composable fun SettingsLayout( - profile: LocalProfile, + profile: LocalProfile?, stopped: Boolean, encrypted: Boolean, passphraseSaved: Boolean, notificationsMode: State, - userDisplayName: String, + userDisplayName: String?, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -150,13 +114,22 @@ fun SettingsLayout( AppBarTitle(stringResource(MR.strings.your_settings)) SectionView(stringResource(MR.strings.settings_section_title_you)) { - SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(profile, stopped = stopped) - } 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) - ChatPreferencesItem(showCustomModal, stopped = stopped) + if (profile != null) { + SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { + ProfilePreview(profile, stopped = stopped) + } + SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) + ChatPreferencesItem(showCustomModal, stopped = stopped) + } else if (chatModel.localUserCreated.value == false) { + SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close -> + LaunchedEffect(Unit) { + closeSettings() + } + CreateProfile(chatModel, close) + } } }, disabled = stopped, extraPadding = true) + } 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() }, disabled = stopped, extraPadding = true) } else { @@ -176,10 +149,12 @@ fun SettingsLayout( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) + if (!chatModel.desktopNoUserNoRemote) { + SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) + } SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true) } SectionDividerSpaced() @@ -469,6 +444,42 @@ fun PreferenceToggleWithIcon( } } +fun doWithAuth(title: String, desc: String, block: () -> Unit) { + val requireAuth = chatModel.controller.appPrefs.performLA.get() + if (!requireAuth) { + block() + } else { + var autoShow = true + ModalManager.fullscreen.showModalCloseable { close -> + val onFinishAuth = { success: Boolean -> + if (success) { + close() + block() + } + } + + LaunchedEffect(Unit) { + if (autoShow) { + autoShow = false + runAuth(title, desc, onFinishAuth) + } + } + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } + } + } +} + private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) { authenticate( title, 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 401c3afedd..390f1cac9b 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +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 @@ -56,7 +57,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.end.closeModals() } withBGApi { - m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + controller.showProgressIfNeeded { + m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + } } }, removeUser = { user -> 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 22f5d2fbec..e0b8f130db 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -18,6 +18,7 @@ Opening database… Invalid file path You shared an invalid file path. Report the issue to the app developers. + View crashed connected @@ -565,6 +566,7 @@ Your settings Your SimpleX address Your chat profiles + Create chat profile Database passphrase & export About SimpleX Chat How to use it @@ -1659,11 +1661,12 @@ Unlink desktop? Unlink Disconnect + Disconnect mobiles %s was disconnected]]> Disconnect desktop? Only one device can work at the same time Use from desktop in mobile app and scan QR code.]]> - %s]]> + Waiting for mobile to connect: Bad desktop address Incompatible version Desktop app version %s is not compatible with this app. @@ -1689,6 +1692,11 @@ Paste desktop address Desktop Not compatible! + Refresh + No connected mobile + Random + Open port in firewall + To allow a mobile app to connect to the desktop, open this port in your firewall, if you have it enabled Coming soon! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 13b5cb2394..1f8f75c126 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1581,7 +1581,7 @@ Schnellerer Gruppenbeitritt und zuverlässigere Nachrichtenzustellung. Verknüpfte Mobiltelefone Dieser Gerätename - %s warten]]> + Auf die Mobiltelefonverbindung warten: Laden der Datei Zu einem Mobiltelefon verbinden Vom Desktop aus nutzen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index a9d315f680..efe9b1003f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1486,7 +1486,7 @@ Bureau Connecté au bureau Ce nom d\'appareil - %s]]> + En attente d\'une connexion mobile: Chargement du fichier Connexion au bureau Appareils de bureau diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg new file mode 100644 index 0000000000..a1f27d5798 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 0883adfdbb..9ed305da76 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1517,6 +1517,6 @@ - avvisa facoltativamente i contatti eliminati. \n- nomi del profilo con spazi. \n- e molto altro! - %s]]> + In attesa che il cellulare si connette: autore \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 0a258fdcb1..6cef9571da 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1516,7 +1516,7 @@ \n- en meer! %s is verbroken]]> auteur - %s]]> + Wachten tot mobiel verbinding maakt: Automatisch verbinden Wachten op desktop… Desktop gevonden diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a9549080a8..8f48724788 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1496,7 +1496,7 @@ Szybsze dołączenie i bardziej niezawodne wiadomości. Połączone telefony Nazwa tego urządzenia - %s]]> + Oczekiwanie na połączenie telefonu: Ładowanie pliku Znaleziono komputer Urządzenia komputerowe diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index dc5771316c..f26f77c33b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1601,7 +1601,7 @@ Проверить соединение Соединяться автоматически Ожидается подключение… - %s]]> + Ожидается подключение мобильного: Компьютер найден Несовместимая версия! автор diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 2f2c27e03d..ed2b9986c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1517,7 +1517,7 @@ - 可选择通知已删除的联系人。 \n- 带空格的个人资料名称。 \n- 以及更多! - %s 进行连接]]> + 正等待移动设备 进行连接: 作者 自动连接 等待桌面中… diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 1a95317a6a..12bead3663 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -9,77 +9,142 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView -import chat.simplex.common.views.helpers.FileDialogChooser -import chat.simplex.common.views.helpers.escapedHtmlToAnnotatedString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener import java.io.File +import kotlin.system.exitProcess val simplexWindowState = SimplexWindowState() -fun showApp() = application { - // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, - // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here - val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp - val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp) +fun showApp() { + val closedByError = mutableStateOf(true) + while (closedByError.value) { + application(exitProcessOnExit = false) { + CompositionLocalProvider( + LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> + WindowExceptionHandler { e -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_was_crashed), + text = e.stackTraceToString() + ) + Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + closedByError.value = true + // If the left side of screen has open modal, it's probably caused the crash + if (ModalManager.start.hasModalsOpen()) { + ModalManager.start.closeModal() + } else if (ModalManager.center.hasModalsOpen() || ModalManager.end.hasModalsOpen()) { + ModalManager.center.closeModal() + ModalManager.end.closeModal() + // Better to not close fullscreen since it can contain passcode + } else { + // The last possible cause that can be closed + chatModel.chatId.value = null + chatModel.chatItems.clear() + } + chatModel.activeCall.value?.let { + withBGApi { + chatModel.callManager.endCall(it) + } + } + } + } + ) { + AppWindow(closedByError) + } + } + } + exitProcess(0) +} + +@Composable +private fun ApplicationScope.AppWindow(closedByError: MutableState) { + // Creates file if not exists; comes with proper defaults + val state = getStoredWindowState() + val windowState: WindowState = rememberWindowState( + placement = WindowPlacement.Floating, + width = state.width.dp, + height = state.height.dp, + position = WindowPosition(state.x.dp, state.y.dp) + ) + + LaunchedEffect( + windowState.position.x.value, + windowState.position.y.value, + windowState.size.width.value, + windowState.size.height.value + ) { + storeWindowState( + WindowPositionSize( + x = windowState.position.x.value.toInt(), + y = windowState.position.y.value.toInt(), + width = windowState.size.width.value.toInt(), + height = windowState.size.height.value.toInt() + ) + ) + } + simplexWindowState.windowState = windowState // Reload all strings in all @Composable's after language change at runtime if (remember { ChatController.appPrefs.appLanguage.state }.value != "") { - Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = { + Window(state = windowState, onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = { if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) { simplexWindowState.backstack.lastOrNull()?.invoke() != null } else { false } }, title = "SimpleX") { - SimpleXTheme { - AppScreen() - if (simplexWindowState.openDialog.isAwaiting) { - FileDialogChooser( - title = "SimpleX", - isLoad = true, - params = simplexWindowState.openDialog.params, - onResult = { - simplexWindowState.openDialog.onResult(it.firstOrNull()) - } - ) - } + simplexWindowState.window = window + AppScreen() + if (simplexWindowState.openDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = true, + params = simplexWindowState.openDialog.params, + onResult = { + simplexWindowState.openDialog.onResult(it.firstOrNull()) + } + ) + } - if (simplexWindowState.openMultipleDialog.isAwaiting) { - FileDialogChooser( - title = "SimpleX", - isLoad = true, - params = simplexWindowState.openMultipleDialog.params, - onResult = { - simplexWindowState.openMultipleDialog.onResult(it) - } - ) - } + if (simplexWindowState.openMultipleDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = true, + params = simplexWindowState.openMultipleDialog.params, + onResult = { + simplexWindowState.openMultipleDialog.onResult(it) + } + ) + } - if (simplexWindowState.saveDialog.isAwaiting) { - FileDialogChooser( - title = "SimpleX", - isLoad = false, - params = simplexWindowState.saveDialog.params, - onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) } - ) - } - val toasts = remember { simplexWindowState.toasts } - val toast = toasts.firstOrNull() - if (toast != null) { + if (simplexWindowState.saveDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = false, + params = simplexWindowState.saveDialog.params, + onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) } + ) + } + val toasts = remember { simplexWindowState.toasts } + val toast = toasts.firstOrNull() + if (toast != null) { + SimpleXTheme { Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) { Text( escapedHtmlToAnnotatedString(toast.first, LocalDensity.current), @@ -88,11 +153,11 @@ fun showApp() = application { style = MaterialTheme.typography.body1 ) } - // Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires - LaunchedEffect(toast, toasts.size) { - delay(toast.second) - simplexWindowState.toasts.removeFirst() - } + } + // Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires + LaunchedEffect(toast, toasts.size) { + delay(toast.second) + simplexWindowState.toasts.removeFirst() } } var windowFocused by remember { simplexWindowState.windowFocused } @@ -124,7 +189,7 @@ fun showApp() = application { var hiddenUntilRestart by remember { mutableStateOf(false) } if (!hiddenUntilRestart) { val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp) - Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) { + Window(state = cWindowState, onCloseRequest = { hiddenUntilRestart = true }, title = stringResource(MR.strings.chat_console)) { SimpleXTheme { TerminalView(ChatModel) { hiddenUntilRestart = true } } @@ -141,6 +206,7 @@ class SimplexWindowState { val saveDialog = DialogState() val toasts = mutableStateListOf>() var windowFocused = mutableStateOf(true) + var window: ComposeWindow? = null } data class DialogParams( @@ -169,7 +235,5 @@ class DialogState { @Preview @Composable fun AppPreview() { - SimpleXTheme { - AppScreen() - } + AppScreen() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt new file mode 100644 index 0000000000..2a1a26df95 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt @@ -0,0 +1,36 @@ +package chat.simplex.common + +import chat.simplex.common.model.json +import chat.simplex.common.platform.appPreferences +import chat.simplex.common.platform.desktopPlatform +import kotlinx.serialization.* + +@Serializable +data class WindowPositionSize( + val width: Int = 1366, + val height: Int = 768, + val x: Int = 0, + val y: Int = 0, +) + +fun getStoredWindowState(): WindowPositionSize = + try { + val str = appPreferences.desktopWindowState.get() + var state = if (str == null) { + WindowPositionSize() + } else { + json.decodeFromString(str) + } + + // For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, + // it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here + if (desktopPlatform.isLinux() && state.width == 1366) { + state = state.copy(width = 1376) + } + state + } catch (e: Throwable) { + WindowPositionSize() + } + +fun storeWindowState(state: WindowPositionSize) = + appPreferences.desktopWindowState.set(json.encodeToString(state)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt index 94d42ba792..b5a0e2e008 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt @@ -19,3 +19,9 @@ actual fun getKeyboardState(): State = remember { mutableStateOf( actual fun hideKeyboard(view: Any?) {} actual fun androidIsFinishingMainActivity(): Boolean = false + +actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { + actual override fun uncaughtException(thread: Thread, e: Throwable) { + Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString()) + } +} 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 c2665109f5..22d39409ed 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 @@ -136,7 +136,6 @@ private fun SendStateUpdates() { .collect { call -> val state = call.callState.text val connInfo = call.connectionInfo - // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" val description = call.encryptionStatus + connInfoText chatModel.callCommand.add(WCallCommand.Description(state, description)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt new file mode 100644 index 0000000000..74c490445e --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -0,0 +1,24 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.User +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { + if (user == null) { + Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING * 2.5f)) { + OnboardingActionButton(MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, true, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick) + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, icon = painterResource(MR.images.ic_desktop), onclick = onclick) + } + } else { + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick) + } +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 787e41fd81..e32b0ae79a 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -3,10 +3,6 @@ package chat.simplex.desktop import chat.simplex.common.platform.* import chat.simplex.common.showApp import java.io.File -import java.nio.file.* -import java.nio.file.attribute.BasicFileAttributes -import java.nio.file.attribute.FileTime -import kotlin.io.path.setLastModifiedTime fun main() { initHaskell() diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 510c9c30ba..50d7005e34 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -8,7 +8,7 @@ module Main where import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Monad.Reader +import Control.Monad import qualified Data.Text as T import Simplex.Chat.Bot import Simplex.Chat.Controller diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index 326a8728ac..5fa3fff0a7 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -9,7 +9,7 @@ module Broadcast.Bot where import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Monad.Reader +import Control.Monad import qualified Data.Text as T import Broadcast.Options import Simplex.Chat.Bot diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 3758af2fcb..9a79af4b48 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -83,5 +83,6 @@ mkChatOpts BroadcastBotOpts {coreOptions} = allowInstantFiles = True, autoAcceptFileSize = 0, muteNotifications = True, + markRead = False, maintenance = False } diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 46c71a7967..3f4484eac6 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -8,6 +8,7 @@ module Server where +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (FromJSON, ToJSON) diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 8f28c9013a..0ca8cee789 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -5,10 +5,10 @@ {-# LANGUAGE ScopedTypeVariables #-} module Directory.Options - ( DirectoryOpts (..), - getDirectoryOpts, - mkChatOpts, - ) + ( DirectoryOpts (..), + getDirectoryOpts, + mkChatOpts, + ) where import Options.Applicative @@ -35,8 +35,8 @@ directoryOpts appDir defaultDbFileName = do <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) directoryLog <- - Just <$> - strOption + Just + <$> strOption ( long "directory-file" <> metavar "DIRECTORY_FILE" <> help "Append only log for directory state" @@ -81,5 +81,6 @@ mkChatOpts DirectoryOpts {coreOptions} = allowInstantFiles = True, autoAcceptFileSize = 0, muteNotifications = True, + markRead = False, maintenance = False } diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 291457591f..fb187bbebe 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -15,7 +15,7 @@ where import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Monad.Reader +import Control.Monad import qualified Data.ByteString.Char8 as B import Data.List (sortOn) import Data.Maybe (fromMaybe, maybeToList) diff --git a/cabal.project b/cabal.project index 6801bb923d..0667b83048 100644 --- a/cabal.project +++ b/cabal.project @@ -4,12 +4,14 @@ packages: . with-compiler: ghc-8.10.7 +index-state: 2023-10-06T00:00:00Z + constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 281bdebcb82aed4c8c2c08438b9cafc7908183a1 + tag: a860936072172e261480fa6bdd95203976e366b2 source-repository-package type: git @@ -24,12 +26,12 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/direct-sqlcipher.git - tag: 34309410eb2069b029b8fc1872deb1e0db123294 + tag: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 source-repository-package type: git location: https://github.com/simplex-chat/sqlcipher-simple.git - tag: 5e154a2aeccc33ead6c243ec07195ab673137221 + tag: a46bd361a19376c5211f1058908fc0ae6bf42446 source-repository-package type: git @@ -43,5 +45,11 @@ source-repository-package source-repository-package type: git - location: https://github.com/zw3rk/android-support.git - tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb + location: https://github.com/simplex-chat/android-support.git + tag: 9aa09f148089d6752ce563b14c2df1895718d806 + +-- TODO this fork is only needed to compile with GHC 8.10.7 - it allows previous base version +source-repository-package + type: git + location: https://github.com/simplex-chat/zip.git + tag: bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc diff --git a/docs/guide/app-settings.md b/docs/guide/app-settings.md index 5482a1297f..3a966e18e1 100644 --- a/docs/guide/app-settings.md +++ b/docs/guide/app-settings.md @@ -45,6 +45,8 @@ When people connect to you via this address, you will receive a connection reque If you start receiving too many requests via this address it is always safe to remove it – all the connections you created via this address will remain active, as this address is not used to deliver the messages. +See the comparison with [1-time invitation links](./making-connections.md#comparison-of-1-time-invitation-links-and-simplex-contact-addresses). + Read more in [this post](../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#auto-accept-contact-requests). ### Chat preferences diff --git a/docs/guide/making-connections.md b/docs/guide/making-connections.md index 0cf46aecde..2dd1f4bd77 100644 --- a/docs/guide/making-connections.md +++ b/docs/guide/making-connections.md @@ -13,6 +13,71 @@ Private Connection — connect using an invitation link or QR code via video or Group Chat — Users have the option to create a secret group, share their contact link [which can be deleted later on], or generate a one-time invitation link. +## Your SimpleX contact address + +You can [create an optional long term address](./app-settings.md#your-simplex-contact-address) for other people to connect with you. Unlike 1-time invitation links, these addresses can be used many times, that makes them good to share online, e.g. on social media platforms, or in email signatures. That helps more people discover SimpleX Chat, so please do it! + +When people connect to you via this address, you will receive a connection request that you can accept or reject. You can configure an automatic acceptance of connection request and an automatic welcome message that will be sent to the new contacts. You can also share this address as part of your SimpleX profile, so group members can connect to you, and your contacts can share it with others - if this is something that you want. + +If you start receiving too many requests via this address it is always safe to remove it – all the connections you created via this address will remain active, as this address is not used to deliver the messages. + +### Comparison of 1-time invitation links and SimpleX Contact addresses + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1-time invitation linkSimpleX contact address
Can be used many times?NoYes
Can be included in user profile?No, as it can only be used once.Yes, to allow group members to connect directly, and your contacts to pass it on to their contacts.
When to use it?With somebody you know, via another communication channel or QR code (in person or during a video call)Where many people can see and connect via it, e.g. in email signature, website, social media or group chat.
SecurityMore secure, as can only be used once, and the initial connection request (including profile) is encrypted with double ratchet.Initial connection request is also e2e encrypted, but without double ratchet (it is initialized when request is accepted).
IdentificationBoth sides know who they connect to, as they know with whom and by who the link was shared. You can attach alias to this invitation as soon as you share it or use it, to identify the other person when connection is established.Only the person using the address knows who they connect to, via the channel where they found the address (email, social media, etc.). The address owner can only see the user profile of the request, and has no proof of identity from the person sending the request*.
Advantages over other platformsThere is no direct analogy, other platforms don’t offer one-time invitations without any fixed part identifying the user.Unlike addresses in other platforms, SimpleX addresses are not used to deliver the messages — only the initial connection requests.
It means that removing this address will not break the contacts made via it (like changing an email address would), it would only prevent new connections, which makes it a good solution against spam and abuse.
Vulnerability to attacksUntil the connection is established, anybody who intercepts this link can connect to it, so it has to be verified with the original contact that the connection succeeded.These addresses are vulnerable to connection request spam. Unlike other platforms, you can delete or change the address, without losing any contacts (see above).
Passive attacks on connection linksBoth types of links are not vulnerable if simply observed — they only contain public keys. So they can be safely shared via insecure or public channels, as long as you can confirm that you connected to the intended person.
Active attacks on connection linksIf the link is substituted via the attack on the channel used to share it, the connection security can be compromised, and the original messages monitored (man-in-the-middle attack). If it is a real risk then security code should be verified to mitigate it - doing so proves** that the link and keys were not substituted, and that the end-to-end encryption is secure.
+ +* Adding optional verified identities that we plan in the future will change it — the address owner will have an option to request identity verification before accepting the connection. + +** Connection security code is the cryptographic hash (SHA256) of combined public keys of both sides — there are 2256 possible security codes (1 with 77 zeros – about [1000 times smaller](https://www.wolframalpha.com/input?i=2%5E256) than the estimated number of atoms in the visible universe). + ## Conversation preferences Tap on one of your conversations to open conversation preferences. diff --git a/docs/rfcs/2023-11-21-inactive-group-members.md b/docs/rfcs/2023-11-21-inactive-group-members.md new file mode 100644 index 0000000000..66e58848af --- /dev/null +++ b/docs/rfcs/2023-11-21-inactive-group-members.md @@ -0,0 +1,110 @@ +# Inactive group members + +## Problem + +Group traffic is higher than necessary due to lack of diagnosis of inactive group members. By inactive we understand group members who went offline for indefinitely long time, uninstalled application without leaving group, failed to send x.grp.leave message before deleting connection, or in any other way failed to explicitly communicate further inactivity. + +Currently other group members continue to identify such members as active and to send messages to their connections until exceeding receiving SMP queues quotas, with pending messages being slowly retried even after that. + +## Solution + +Identify inactive members and don't send messages to their connections. Silent periodically online members should continue to receive messages, so decision to mark member as inactive should be made conservatively. + +Agent: +- on SMP.QUOTA error notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA). +- on receiving QCONT notify client (new event). + +Chat, on sending side, per member: +- unanswered_snd_msg_count - number of messages that were sent consecutively without receiving a message from member. +- last_rcv_ts - timestamp of last received message. +- inactive flag. +- set inactive if: + - agent reports QUOTA error. + - on sending message: (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago), Ddiff = 1/2/3 days? +- reset inactive: + - on receiving QCONT. + - on receiving message or receipt. Also reset unanswered_snd_msg_count, last_rcv_ts. +- don't send to member if inactive. + - don't send only content messages (x.msg.new, etc.) and always send messages altering group state? +- unanswered_snd_msg_count, last_rcv_ts to be tracked, checked, reset only for members with compatible version. + +Chat, on receiving side, per member: +- unanswered_rcv_msg_count - number of messages that were received consecutively without sending a message to member. +- send non-optional receipt / another (new) protocol message if: + - on receiving message: unanswered_rcv_msg_count > M, M < K. +- on sending a message or receipt to member reset unanswered_rcv_msg_count. +- unanswered_rcv_msg_count to be tracked, checked, reset only for members with compatible version. + +\*** + +Consider above condition: + +> (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago) + +It still doesn't account for following situation: + +1. Sending member sends a few (N1, N1 < M) messages to silent member on day D1. +2. Sending member doesn't send messages for several days. +3. Sending member sends more messages (N2, N1 + N2 > K) to silent member on day DI (DI - D1 > diff in days in above condition), while silent member is offline. + - Sending member checks above condition and evaluates it to be true, marks silent member as inactive. + - Simply remembering last_snd_ts on sending side and adding check for it not being from several days ago to above condition is not enough, as it will be overwritten by current day sends and will only evaluate false for the first send. What could work is remembering prev_session_last_snd_ts or prev_day_last_snd_ts, but it further complicates logic, and still probably wouldn't account for some time zone differences. +4. Sending member sends yet more messages, which will not be queued for silent member marked inactive. +5. Silent member comes online, sends receipt upon receiving message fulfilling above condition: `unanswered_rcv_msg_count > M`, and will lose following messages. + - If sending member created messages from 4 as pending, and sent them upon receiving receipt from silent member, silent member would only receive them after sending member coming online. If they are in different time zones it may happen on next day. + +Same situation can occur even without step 1, simply by sending many messages while other member is offline. + +The problem is less acute the greater the difference between K and M, but making K >> M renders this whole mechanism obsolete, as we could then simply rely on QUOTA errors to mark group members inactive (and don't slow retry in agent?). + +Perhaps an acceptable way to solve this problem is to add a task to cleanup manager that would send receipts to all members on condition: (unanswered_rcv_msg_count > 0) && (last_reply_ts earlier than 1 day ago). (Adds last_reply_ts to tracking on receiving side). Perhaps it should be a task separate from cleanup manager that only occurs once per start, or with longer interval. + +\*** + +Additionally we could consider group member connection as disabled with smaller AUTH error count. Currently it's 10 messages, could be 1. + +### Delivery suspension notice + +When receiving side comes back online, replies and continues to receive messages, it has no way of knowing there was a gap in messages from sending member. To notify receiving member about delivery suspension, sending member should send notice containing shared message id of the last sent message (new protocol event) to them: + +```haskell +XGrpMemSuspended :: SharedMsgId -> ChatMsgEvent 'Json +``` + +Sending side additionally tracks: +- xgrpmemsuspended_sent flag - to only send it once. + +When processing it, receiving member creates a "gap" chat item (e.g. event saying "member x suspended delivery to you due to your inactivity, there may be a gap in messages"). + +After receiving member signals activity by sending any reply, sending member may send message history before continuing normal delivery. + +Starting point for message history: either receiving member could request history starting from specific shared message id (received in XGrpMemSuspended) with another new protocol event, or sending member can remember it instead of just flag. + +### Sending message history + +New protocol event: + +```haskell +XGrpMsgHistory :: [ChatMessage 'Json] -> ChatMsgEvent 'Json +``` + +Sending member builds messages history starting starting from requested/remembered shared message id: +- `messages` table is periodically cleaned up, so messages would be retrieved from `chat_items`. +- if chat item for starting shared message id is not found (it may have been deleted manually or as a disappearing message), abort? + - sending member could track number of skipped messages per member, but again if any chat items were deleted, older (previously successfully sent) chat items would be retrieved, resulting in duplicate messages. If receiving member has also cleaned up records in `messages` table, they wouldn't be deduplicated. + - sending member could track timestamp of first unsent message instead of shared msg id. +- sending member should probably limit maximum number of messages sent as history (100?). +- only XMsgNew events should be sent in XGrpMsgHistory (chat items to be transformed back into text messages). + - updates, deletions would be reflected in chat item list. + - reactions would be omitted. + - files would be likely expired by the time of sending history, so only file name and size may be sent in FileInvitation, with invitation being practically not acceptable. + - add new flag to CIFile "expired" for receiving member to mark chat items created based on such invitations. + - FileInvitation in MsgContainer could also contain this flag as optional to explicitly communicate that only file metadata is sent. + - alternatively sending member could re-upload files, but this seems excessive. + - XMsgNew events don't include message timestamps (instead usually broker ts is retrieved from agent message meta), so receiving member wouldn't be able to restore them from history. Perhaps history should include XGrpMsgForward events containing XMsgNew events instead. +- XGrpMsgHistory is likely to exceed message block limit. + - either multiple messages comprising a history can be batched as a single message on chat level until the block size is exceeded. + - or large history messages could be batched on agent level. + +\*** + +Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions. diff --git a/fourmolu.yaml b/fourmolu.yaml new file mode 100644 index 0000000000..907a25e7d6 --- /dev/null +++ b/fourmolu.yaml @@ -0,0 +1,30 @@ +indentation: 2 +column-limit: none +function-arrows: trailing +comma-style: trailing +import-export-style: trailing +indent-wheres: true +record-brace-space: true +newlines-between-decls: 1 +haddock-style: single-line +haddock-style-module: null +let-style: inline +in-style: right-align +single-constraint-parens: never +unicode: never +respectful: true +fixities: + - infixr 9 . + - infixr 8 .:, .:., .= + - infixr 6 <> + - infixr 5 ++ + - infixl 4 <$>, <$, $>, <$$>, <$?> + - infixl 4 <*>, <*, *>, <**> + - infix 4 ==, /= + - infixr 3 && + - infixl 3 <|> + - infixr 2 || + - infixl 1 >>, >>= + - infixr 1 =<<, >=>, <=< + - infixr 0 $, $! +reexports: [] diff --git a/package.yaml b/package.yaml index 2af677eece..0f27f6035e 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.6 +version: 5.4.0.7 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -19,11 +19,10 @@ dependencies: - attoparsec == 0.14.* - base >= 4.7 && < 5 - base64-bytestring >= 1.0 && < 1.3 - - bytestring == 0.10.* - composition == 1.0.* - constraints >= 0.12 && < 0.14 - containers == 0.6.* - - cryptonite >= 0.27 && < 0.30 + - cryptonite == 0.30.* - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* @@ -32,8 +31,8 @@ dependencies: - filepath == 1.4.* - http-types == 0.12.* - http2 >= 4.2.2 && < 4.3 - - memory == 0.15.* - - mtl == 2.2.* + - memory == 0.18.* + - mtl >= 2.3.1 && < 3.0 - network >= 3.1.2.7 && < 3.2 - network-transport == 0.5.6 - optparse-applicative >= 0.15 && < 0.17 @@ -45,14 +44,12 @@ dependencies: - socks == 0.6.* - sqlcipher-simple == 0.4.* - stm == 2.5.* - - template-haskell == 2.16.* - 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.* + - zip == 2.0.* flags: swift: @@ -64,6 +61,16 @@ when: - condition: flag(swift) cpp-options: - -DswiftJSON + - condition: impl(ghc >= 9.6.2) + dependencies: + - bytestring == 0.11.* + - template-haskell == 2.20.* + - text >= 2.0.1 && < 2.2 + - condition: impl(ghc < 9.6.2) + dependencies: + - bytestring == 0.10.* + - template-haskell == 2.16.* + - text >= 1.2.3.0 && < 1.3 library: source-dirs: src diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts index 0e1b2799b1..b50b2e2943 100644 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ b/packages/simplex-chat-client/typescript/src/response.ts @@ -86,7 +86,6 @@ export type ChatResponse = | CRGroupUpdated | CRUserContactLinkSubscribed | CRUserContactLinkSubError - | CRNewContactConnection | CRContactConnectionDeleted | CRMessageError | CRChatCmdError @@ -731,12 +730,6 @@ export interface CRUserContactLinkSubError extends CR { chatError: ChatError } -export interface CRNewContactConnection extends CR { - type: "newContactConnection" - user: User - connection: PendingContactConnection -} - export interface CRContactConnectionDeleted extends CR { type: "contactConnectionDeleted" user: User diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d933165f67..e34847138f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,10 +1,11 @@ { - "https://github.com/simplex-chat/simplexmq.git"."281bdebcb82aed4c8c2c08438b9cafc7908183a1" = "0dly5rnpcnb7mbfxgpxna5xbabk6n0dh5qz53nm4l93gzdy18hpb"; + "https://github.com/simplex-chat/simplexmq.git"."a860936072172e261480fa6bdd95203976e366b2" = "16rwnh5zzphmw8d8ypvps6xjvzbmf5ljr6zzy15gz2g0jyh7hd91"; "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"; - "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; + "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; + "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; - "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; + "https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn"; + "https://github.com/simplex-chat/zip.git"."bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc" = "1csqfjhvc8wb5h4kxxndmb6iw7b4ib9ff2n81hrizsmnf45a6gg0"; } diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bf7aa7c6b9..84ad7cd926 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.6 +version: 5.4.0.7 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -124,6 +124,7 @@ library Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Migrations.M20231114_remote_control + Simplex.Chat.Migrations.M20231126_remote_ctrl_address Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -170,11 +171,10 @@ library , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -183,10 +183,10 @@ library , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -196,17 +196,25 @@ library , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 executable simplex-bot main-is: Main.hs @@ -222,11 +230,10 @@ executable simplex-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -235,10 +242,10 @@ executable simplex-bot , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -249,17 +256,25 @@ executable simplex-bot , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 executable simplex-bot-advanced main-is: Main.hs @@ -275,11 +290,10 @@ executable simplex-bot-advanced , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -288,10 +302,10 @@ executable simplex-bot-advanced , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -302,17 +316,25 @@ executable simplex-bot-advanced , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 executable simplex-broadcast-bot main-is: ../Main.hs @@ -330,11 +352,10 @@ executable simplex-broadcast-bot , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -343,10 +364,10 @@ executable simplex-broadcast-bot , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -357,17 +378,25 @@ executable simplex-broadcast-bot , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -384,11 +413,10 @@ executable simplex-chat , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -397,10 +425,10 @@ executable simplex-chat , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -411,18 +439,26 @@ executable simplex-chat , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* - , zip ==1.7.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 executable simplex-directory-service main-is: ../Main.hs @@ -442,11 +478,10 @@ executable simplex-directory-service , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -455,10 +490,10 @@ executable simplex-directory-service , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -469,17 +504,25 @@ executable simplex-directory-service , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 test-suite simplex-chat-test type: exitcode-stdio-1.0 @@ -523,11 +566,10 @@ test-suite simplex-chat-test , attoparsec ==0.14.* , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 - , bytestring ==0.10.* , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite >=0.27 && <0.30 + , cryptonite ==0.30.* , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* @@ -539,10 +581,10 @@ test-suite simplex-chat-test , hspec ==2.7.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.15.* - , mtl ==2.2.* + , memory ==0.18.* + , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.4 + , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -554,14 +596,22 @@ test-suite simplex-chat-test , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.16.* , 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.* + , zip ==2.0.* default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , template-haskell ==2.20.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , template-haskell ==2.16.* + , text >=1.2.3.0 && <1.3 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f9eecac67c..bcd533a9a1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -11,12 +11,14 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) import Control.Logger.Simple +import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader @@ -101,7 +103,8 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version -import Simplex.RemoteControl.Invitation (RCSignedInvitation (..), RCInvitation (..)) +import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) +import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitFailure, exitSuccess) import System.FilePath (takeFileName, ()) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout) @@ -109,8 +112,8 @@ 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 qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM @@ -231,8 +234,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen contactMergeEnabled <- newTVarIO True pure ChatController - { - firstTime, + { firstTime, currentUser, currentRemoteHost, smpAgent, @@ -441,7 +443,7 @@ processChatCommand = \case [] -> pure 1 users -> do when (any (\User {localDisplayName = n} -> n == displayName) users) $ - throwChatError $ CEUserExists displayName + throwChatError (CEUserExists displayName) withAgent (\a -> createUser a smp xftp) ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts @@ -464,8 +466,8 @@ processChatCommand = \case defServers <- asks $ defaultServers . config pure (cfgServers protocol defServers, []) storeServers user servers = - unless (null servers) $ - withStore $ \db -> overwriteProtocolServers db user servers + unless (null servers) . withStore $ + \db -> overwriteProtocolServers db user servers coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withStoreCtx' (Just "ListUsers, getUsersInfo") getUsersInfo @@ -775,7 +777,9 @@ processChatCommand = \case MCVoice {} -> False MCUnknown {} -> True qText = msgContentText qmc - qFileName = maybe qText (T.pack . (fileName :: CIFile d -> String)) ciFile_ + getFileName :: CIFile d -> String + getFileName CIFile {fileName} = fileName + qFileName = maybe qText (T.pack . getFileName) ciFile_ qTextOrFile = if T.null qText then qFileName else qText xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do @@ -798,7 +802,8 @@ processChatCommand = \case -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $ - withStore' $ \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr + withStore' $ + \db -> createSndFTDescrXFTP db user (Just m) conn ft fileDescr saveMemberFD _ = pure () pure (fInv, ciFile, ft) unzipMaybe3 :: Maybe (a, b, c) -> (Maybe a, Maybe b, Maybe c) @@ -890,9 +895,9 @@ processChatCommand = \case withStore (\db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ - throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) + throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) unless (ciReactionAllowed ci) $ - throwChatError $ CECommandError "reaction not allowed - chat item has no content" + throwChatError (CECommandError "reaction not allowed - chat item has no content") rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True checkReactionAllowed rs (SndMessage {msgId}, _) <- sendDirectContactMessage ct $ XMsgReact itemSharedMId Nothing reaction add @@ -908,9 +913,9 @@ processChatCommand = \case withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (groupFeatureAllowed SGFReactions g) $ - throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) + throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) unless (ciReactionAllowed ci) $ - throwChatError $ CECommandError "reaction not allowed - chat item has no content" + throwChatError (CECommandError "reaction not allowed - chat item has no content") let GroupMember {memberId = itemMemberId} = chatItemMember g ci rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs @@ -928,9 +933,9 @@ processChatCommand = \case where checkReactionAllowed rs = do when ((reaction `elem` rs) == add) $ - throwChatError $ CECommandError $ "reaction already " <> if add then "added" else "removed" + throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ - throwChatError $ CECommandError "too many reactions" + throwChatError (CECommandError "too many reactions") APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withStore $ \db -> getUserByContactId db chatId @@ -1167,7 +1172,9 @@ processChatCommand = \case APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs - msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta + getMsgTs :: SMP.NMsgMeta -> SystemTime + getMsgTs SMP.NMsgMeta {msgTs} = msgTs + msgTs' = systemToUTCTime . getMsgTs <$> ntfMsgMeta agentConnId = AgentConnId ntfConnId user_ <- withStore' (`getUserByAConnId` agentConnId) connEntity <- @@ -1247,9 +1254,10 @@ processChatCommand = \case m <- withStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings getGroupMember db user gId gMemberId - when (memberActive m) $ forM_ (memberConnId m) $ \connId -> do - let ntfOn = showMessages $ memberSettings m - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + when (memberActive m) $ + forM_ (memberConnId m) $ \connId -> do + let ntfOn = showMessages $ memberSettings m + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact @@ -1395,7 +1403,6 @@ processChatCommand = \case subMode <- chatReadVar subscriptionMode (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile subMode - toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq conn AddContact incognito -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId incognito @@ -1414,8 +1421,9 @@ processChatCommand = \case case conn'_ of Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited - APIConnectPlan userId cReqUri -> withUserId userId $ \user -> withChatLock "connectPlan" . procCmd $ - CRConnectionPlan user <$> connectPlan user cReqUri + APIConnectPlan userId cReqUri -> withUserId userId $ \user -> + withChatLock "connectPlan" . procCmd $ + CRConnectionPlan user <$> connectPlan user cReqUri APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send @@ -1424,8 +1432,7 @@ processChatCommand = \case dm <- directMessage $ XInfo profileToSend connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined (incognitoProfile $> profileToSend) subMode - toView $ CRNewContactConnection user conn - pure $ CRSentConfirmation user + pure $ CRSentConfirmation user conn APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do @@ -1957,16 +1964,16 @@ 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@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ - pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port} - 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_ + SetLocalDeviceName name -> chatWriteVar localDeviceName name >> ok_ + ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts + SwitchRemoteHost rh_ -> CRCurrentRemoteHost <$> switchRemoteHost rh_ + StartRemoteHost rh_ ca_ bp_ -> do + (localAddrs, remoteHost_, inv@RCSignedInvitation {invitation = RCInvitation {port}}) <- startRemoteHost rh_ ca_ bp_ + pure CRRemoteHostStarted {remoteHost_, invitation = decodeLatin1 $ strEncode inv, ctrlPort = show port, localAddrs} + StopRemoteHost rh_ -> closeRemoteHost rh_ >> ok_ + DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_ + StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath + GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_ ConnectRemoteCtrl inv -> withUser_ $ do (remoteCtrl_, ctrlAppInfo) <- connectRemoteCtrlURI inv pure CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion = currentAppVersion} @@ -2096,8 +2103,7 @@ processChatCommand = \case connect' groupLinkId cReqHash xContactId = do (connId, incognitoProfile, subMode) <- requestContact user incognito cReq xContactId conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode - toView $ CRNewContactConnection user conn - pure $ CRSentInvitation user incognitoProfile + pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> m ChatResponse connectContactViaAddress user incognito ct cReq = withChatLock "connectViaContact" $ do @@ -2345,12 +2351,12 @@ processChatCommand = \case let Connection {connStatus, contactConnInitiated} = conn if | connStatus == ConnNew && contactConnInitiated -> - pure $ CPInvitationLink ILPOwnLink + pure $ CPInvitationLink ILPOwnLink | not (connReady conn) -> - pure $ CPInvitationLink (ILPConnecting ct_) + pure $ CPInvitationLink (ILPConnecting ct_) | otherwise -> case ct_ of - Just ct -> pure $ CPInvitationLink (ILPKnown ct) - Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" where cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) @@ -2396,7 +2402,7 @@ processChatCommand = \case (Nothing, Just _) -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" (Just gInfo@GroupInfo {membership}, _) | not (memberActive membership) && not (memberRemoved membership) -> - pure $ CPGroupLink (GLPConnectingProhibit gInfo_) + pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) | otherwise -> pure $ CPGroupLink GLPOk where @@ -2414,7 +2420,7 @@ processChatCommand = \case assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ - throwChatError $ CEDirectMessagesProhibited dir ct + throwChatError (CEDirectMessagesProhibited dir ct) where directMessagesAllowed = any (groupFeatureAllowed' SGFDirectMessages) <$> withStore' (\db -> getContactGroupPreferences db user ct) allowedChatEvent = case event of @@ -2631,7 +2637,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI rcvInline_ /= Just False && fileInline == Just IFMOffer && ( fileSize <= fileChunkSize * receiveChunks - || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) + || (rcvInline_ == Just True && fileSize <= fileChunkSize * offerChunks) ) receiveViaCompleteFD :: ChatMonad m => User -> FileTransferId -> RcvFileDescr -> Maybe CryptoFileArgs -> m () @@ -2787,7 +2793,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce --- TODO possibly, we could either disable these events or replace with less noisy for API + -- TODO possibly, we could either disable these events or replace with less noisy for API contactLinkSubsToView rs ucs groupSubsToView rs gs ms ce sndFileSubsToView rs sfts @@ -2858,13 +2864,13 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) - statuses = M.foldrWithKey' addStatus [] cts + statuses = M.foldrWithKey' addStatus [] cts where addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] addStatus _ Contact {activeConn = Nothing} nss = nss addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss = let ns = (agentConnId, netStatus $ resultErr connId rs) - in ns : nss + in ns : nss netStatus :: Maybe ChatError -> NetworkStatus netStatus = maybe NSConnected $ NSError . errorNetworkStatus errorNetworkStatus :: ChatError -> String @@ -2872,7 +2878,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do ChatErrorAgent (BROKER _ NETWORK) _ -> "network" ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted" e -> show e --- TODO possibly below could be replaced with less noisy events for API + -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () @@ -3663,15 +3669,16 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ (forwardedGroupMsg chatMsg) $ \chatMsg' -> do ChatConfig {highlyAvailable} <- asks config -- members introduced to this invited member - introducedMembers <- if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable - else pure [] + introducedMembers <- + if memberCategory m == GCInviteeMember + then withStore' $ \db -> getForwardIntroducedMembers db user m highlyAvailable + else pure [] -- invited members to which this member was introduced invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db user m highlyAvailable let ms = introducedMembers <> invitedMembers msg = XGrpMsgForward (memberId (m :: GroupMember)) chatMsg' brokerTs - unless (null ms) $ - void $ sendGroupMessage user gInfo ms msg + unless (null ms) . void $ + sendGroupMessage user gInfo ms msg RCVD msgMeta msgRcpt -> withAckMessage' agentConnId conn msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -4041,9 +4048,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- if doProbeContacts - then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) - else pure [] + cs <- + if doProbeContacts + then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + else pure [] ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) sendProbeHashes (cs <> ms) probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) @@ -4700,8 +4708,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash forM_ cgm2Probe_ $ \(cgm2, probe) -> - unless (contactOrMemberIncognito cgm2) $ - void $ probeMatch cgm1 cgm2 probe + unless (contactOrMemberIncognito cgm2) . void $ + probeMatch cgm1 cgm2 probe probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> m (Maybe ContactOrMember) probeMatch cgm1 cgm2 probe = @@ -4983,7 +4991,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do 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 + withStore' $ + \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () @@ -5037,9 +5046,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (GCInviteeMember, GCInviteeMember) -> withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case Right intro -> inviteeXGrpMemCon intro - Left _ -> withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" + Left _ -> + withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case + Right intro -> forwardMemberXGrpMemCon intro + Left _ -> messageWarning "x.grp.mem.con: no introduction" (GCInviteeMember, _) -> withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case Right intro -> inviteeXGrpMemCon intro @@ -5371,7 +5381,7 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileInvitation, fileStatus, cryptoAr append_ filePath = do fsFilePath <- toFSFilePath filePath h <- getFileHandle fileId fsFilePath rcvFiles AppendMode - liftIO (B.hPut h chunk >> hFlush h) `catchThrow` (fileErr . show) + liftIO (B.hPut h chunk >> hFlush h) `catchThrow` (fileErr . show) withStore' $ \db -> updatedRcvFileChunkStored db ft chunkNo when final $ do closeFileHandle fileId rcvFiles @@ -5602,14 +5612,15 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} amId = Just $ groupMemberId' am' - msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery amId) - `catchChatError` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId - forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMessage fmConn (XGrpMemCon $ memberId (am' :: GroupMember)) (GroupId groupId) - throwError e - _ -> throwError e + msg <- + withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery amId) + `catchChatError` \e -> case e of + ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do + fm <- withStore $ \db -> getGroupMember db user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMessage fmConn (XGrpMemCon $ memberId (am' :: GroupMember)) (GroupId groupId) + throwError e + _ -> throwError e pure (am', conn', msg) saveGroupFwdRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> m RcvMessage @@ -5878,10 +5889,10 @@ getCreateActiveUser st testView = do displayName <- getWithPrompt "display name" let validName = mkValidName displayName if - | null displayName -> putStrLn "display name can't be empty" >> getContactName - | null validName -> putStrLn "display name is invalid, please choose another" >> getContactName - | displayName /= validName -> putStrLn ("display name is invalid, you could use this one: " <> validName) >> getContactName - | otherwise -> pure $ T.pack displayName + | null displayName -> putStrLn "display name can't be empty" >> getContactName + | null validName -> putStrLn "display name is invalid, please choose another" >> getContactName + | displayName /= validName -> putStrLn ("display name is invalid, you could use this one: " <> validName) >> getContactName + | otherwise -> pure $ T.pack displayName getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine @@ -6177,7 +6188,7 @@ chatCommandP = "/set device name " *> (SetLocalDeviceName <$> textP), "/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))))), + "/start remote host " *> (StartRemoteHost <$> ("new" $> Nothing <|> (Just <$> ((,) <$> A.decimal <*> (" multicast=" *> onOffP <|> pure False)))) <*> optional (A.space *> rcCtrlAddressP) <*> optional (" port=" *> A.decimal)), "/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), @@ -6315,6 +6326,8 @@ chatCommandP = (pure Nothing) srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True} + rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> text1P) + text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char adminContactReq :: ConnReqContact @@ -6322,13 +6335,14 @@ adminContactReq = either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" simplexContactProfile :: Profile -simplexContactProfile = Profile { - displayName = "SimpleX Chat team", - fullName = "", - image = Just (ImageData "data:image/jpg;base64,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"), - contactLink = Just adminContactReq, - preferences = Nothing -} +simplexContactProfile = + Profile + { displayName = "SimpleX Chat team", + fullName = "", + image = Just (ImageData "data:image/jpg;base64,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"), + contactLink = Just adminContactReq, + preferences = Nothing + } timeItToView :: ChatMonad' m => String -> m a -> m a timeItToView s action = do @@ -6345,15 +6359,15 @@ mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 fst3 (x, _, _) = x addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) where - c' = if isSpace c then ' ' else c - punct' - | isPunctuation c = punct + 1 - | isSpace c = punct - | otherwise = 0 - validChar - | c == '\'' = False - | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar - | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) - | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) - | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c - validFirstChar = isLetter c || isNumber c || isSymbol c + c' = if isSpace c then ' ' else c + punct' + | isPunctuation c = punct + 1 + | isSpace c = punct + | otherwise = 0 + validChar + | c == '\'' = False + | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar + | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) + | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) + | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c + validFirstChar = isLetter c || isNumber c || isSymbol c diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 80307f491b..22e5f1ee2f 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -14,6 +14,7 @@ module Simplex.Chat.Archive where import qualified Codec.Archive.Zip as Z +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Functor (($>)) @@ -21,7 +22,7 @@ import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, sqlString) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 4c0d37605e..3f7e2c2f09 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Bot where import Control.Concurrent.Async import Control.Concurrent.STM +import Control.Monad import Control.Monad.Reader import qualified Data.ByteString.Char8 as B import qualified Data.Text as T diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index c079b994a6..1ea44d49be 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -6,8 +6,8 @@ module Simplex.Chat.Bot.KnownContacts where import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Util (safeDecodeUtf8) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 313442838e..115cd839e4 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -225,4 +225,3 @@ 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 3c0054ec1b..fb2ff89a28 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1,5 +1,5 @@ -{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE CPP #-} +{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -41,6 +41,7 @@ import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) +import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -426,19 +427,19 @@ data ChatCommand | 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 + | StartRemoteHost (Maybe (RemoteHostId, Bool)) (Maybe RCCtrlAddress) (Maybe Word16) -- 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 + | 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 + | 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 @@ -469,7 +470,7 @@ allowRemoteCommand = \case APIGetNetworkConfig -> False SetLocalDeviceName _ -> False ListRemoteHosts -> False - StartRemoteHost _ -> False + StartRemoteHost {} -> False SwitchRemoteHost {} -> False StoreRemoteFile {} -> False GetRemoteFile {} -> False @@ -556,8 +557,8 @@ data ChatResponse | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} - | CRSentConfirmation {user :: User} - | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} + | CRSentConfirmation {user :: User, connection :: PendingContactConnection} + | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} @@ -654,11 +655,10 @@ data ChatResponse | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} | 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, ctrlPort :: String} + | CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String, localAddrs :: NonEmpty RCCtrlAddress} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text} | CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo} @@ -1072,32 +1072,33 @@ 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 + = 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 + | RHEBadState -- Illegal state transition | RHEBadVersion {appVersion :: AppVersion} - | RHELocalCommand -- ^ Command not allowed for remote execution + | RHELocalCommand -- Command not allowed for remote execution | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected? | RHEProtocolError RemoteProtocolError deriving (Show, Exception) data RemoteHostStopReason - = RHSRConnectionFailed ChatError - | RHSRCrashed ChatError + = RHSRConnectionFailed {chatError :: ChatError} + | RHSRCrashed {chatError :: ChatError} | RHSRDisconnected 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 + = 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 + | RCENoKnownControllers -- No previously-contacted controllers to discover + | RCEBadController -- Attempting to confirm a found controller with another ID + | -- | A session disconnected by a controller + RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} | RCEBadInvitation | RCEBadVersion {appVersion :: AppVersion} | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used @@ -1105,9 +1106,9 @@ data RemoteCtrlError deriving (Show, Exception) data RemoteCtrlStopReason - = RCSRDiscoveryFailed ChatError - | RCSRConnectionFailed ChatError - | RCSRSetupFailed ChatError + = RCSRDiscoveryFailed {chatError :: ChatError} + | RCSRConnectionFailed {chatError :: ChatError} + | RCSRSetupFailed {chatError :: ChatError} | RCSRDisconnected deriving (Show, Exception) @@ -1223,8 +1224,8 @@ toView event = do session <- asks remoteCtrlSession atomically $ readTVar session >>= \case - Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> - writeTBQueue remoteOutputQ event + Just (_, RCSessionConnected {remoteOutputQ}) + | allowRemoteEvent event -> writeTBQueue remoteOutputQ event -- TODO potentially, it should hold some events while connecting _ -> writeTBQueue localQ (Nothing, Nothing, event) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index c5eb19f286..0706dda084 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -35,9 +35,9 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController True True True) cc - a2 <- async $ chat u cc - waitEither_ a1 a2 + a1 <- runReaderT (startChatController True True True) cc + a2 <- async $ chat u cc + waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO ChatResponse sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc diff --git a/src/Simplex/Chat/Files.hs b/src/Simplex/Chat/Files.hs index 845b237cdf..9c6d731dd7 100644 --- a/src/Simplex/Chat/Files.hs +++ b/src/Simplex/Chat/Files.hs @@ -6,8 +6,8 @@ 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) +import System.FilePath (combine, splitExtensions) +import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getHomeDirectory, getTemporaryDirectory) uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath uniqueCombine fPath fName = tryCombine (0 :: Int) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index f992b4574a..6ee4898e3d 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -19,7 +19,7 @@ import qualified Data.Attoparsec.Text as A import Data.Char (isDigit, isPunctuation) import Data.Either (fromRight) import Data.Functor (($>)) -import Data.List (intercalate, foldl') +import Data.List (foldl', intercalate) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe, isNothing) @@ -85,16 +85,18 @@ 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 + 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 @@ -167,14 +169,14 @@ markdownP = mconcat <$> A.many' fragmentP md :: Char -> Format -> Text -> Markdown md c f s | T.null s || T.head s == ' ' || T.last s == ' ' = - unmarked $ c `T.cons` s `T.snoc` c + unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s secretP :: Parser Markdown secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#') secret :: Text -> Text -> Text -> Markdown secret b s a | T.null a || T.null s || T.head s == ' ' || T.last s == ' ' = - unmarked $ '#' `T.cons` ss + unmarked $ '#' `T.cons` ss | otherwise = markdown Secret $ T.init ss where ss = b <> s <> a @@ -215,9 +217,9 @@ markdownP = mconcat <$> A.many' fragmentP wordMD s | T.null s = unmarked s | isUri s = - let t = T.takeWhileEnd isPunctuation s - uri = uriMarkdown $ T.dropWhileEnd isPunctuation s - in if T.null t then uri else uri :|: unmarked t + let t = T.takeWhileEnd isPunctuation s + uri = uriMarkdown $ T.dropWhileEnd isPunctuation s + in if T.null t then uri else uri :|: unmarked t | isEmail s = markdown Email s | otherwise = unmarked s uriMarkdown s = case strDecode $ encodeUtf8 s of diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 709deeb05a..c82ecc110e 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -10,6 +10,7 @@ {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Messages where @@ -41,7 +42,7 @@ 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 (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 8d5e2ddd8b..6b7e66bdb3 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -311,7 +311,7 @@ profileToText Profile {displayName, fullName} = displayName <> optionalFullName msgIntegrityError :: MsgErrorType -> Text msgIntegrityError = \case MsgSkipped fromId toId -> - "skipped message ID " <> tshow fromId + ("skipped message ID " <> tshow fromId) <> if fromId == toId then "" else ".." <> tshow toId MsgBadId msgId -> "unexpected message ID " <> tshow msgId MsgBadHash -> "incorrect message hash" diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 42a5add1d6..16851859e3 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -46,9 +46,9 @@ data SndConnEvent | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} deriving (Show) -data RcvDirectEvent = - -- RDEProfileChanged {...} - RDEContactDeleted +data RcvDirectEvent + = -- RDEProfileChanged {...} + RDEContactDeleted deriving (Show) -- platform-specific JSON encoding (used in API) diff --git a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs b/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs new file mode 100644 index 0000000000..343e4ca6fa --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231126_remote_ctrl_address where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231126_remote_ctrl_address :: Query +m20231126_remote_ctrl_address = + [sql| +ALTER TABLE remote_hosts ADD COLUMN bind_addr TEXT; +ALTER TABLE remote_hosts ADD COLUMN bind_iface TEXT; +ALTER TABLE remote_hosts ADD COLUMN bind_port INTEGER; +|] + +down_m20231126_remote_ctrl_address :: Query +down_m20231126_remote_ctrl_address = + [sql| +ALTER TABLE remote_hosts DROP COLUMN bind_addr; +ALTER TABLE remote_hosts DROP COLUMN bind_iface; +ALTER TABLE remote_hosts DROP COLUMN bind_port; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index bc441ec6ff..19b4d72379 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -537,6 +537,10 @@ CREATE TABLE remote_hosts( 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 + , + bind_addr TEXT, + bind_iface TEXT, + bind_port INTEGER ); CREATE TABLE remote_controllers( -- e.g., desktops known to a mobile app diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 9127102543..69f6887403 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -4,13 +4,12 @@ {-# 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.Exception (SomeException, catch) import Control.Monad.Except import Control.Monad.Reader import qualified Data.Aeson as J @@ -31,7 +30,7 @@ import Foreign.C.Types (CInt (..)) import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) -import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) +import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding) import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) @@ -190,6 +189,7 @@ mobileChatOpts dbFilePrefix dbKey = allowInstantFiles = True, autoAcceptFileSize = 0, muteNotifications = True, + markRead = False, maintenance = True } @@ -219,7 +219,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do ExceptT $ (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) `catch` (pure . checkDBError) - `catchAll` (pure . dbError) + `catchAll` (pure . dbError) where checkDBError e = case sqlError e of DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile @@ -233,7 +233,7 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) - + chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString chatSendCmd cc = chatSendRemoteCmd cc Nothing diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 284f56902c..1da64a3044 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -16,6 +16,7 @@ module Simplex.Chat.Mobile.File ) where +import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import qualified Data.Aeson as J diff --git a/src/Simplex/Chat/Mobile/Shared.hs b/src/Simplex/Chat/Mobile/Shared.hs index d1f60ffce5..d55ccc7969 100644 --- a/src/Simplex/Chat/Mobile/Shared.hs +++ b/src/Simplex/Chat/Mobile/Shared.hs @@ -6,8 +6,8 @@ import qualified Data.ByteString as B import Data.ByteString.Internal (ByteString (..), memcpy) import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Internal as LB -import Foreign.C (CInt, CString) import Foreign +import Foreign.C (CInt, CString) type CJSONString = CString diff --git a/src/Simplex/Chat/Mobile/WebRTC.hs b/src/Simplex/Chat/Mobile/WebRTC.hs index 19ba2b751b..422cfd5a8c 100644 --- a/src/Simplex/Chat/Mobile/WebRTC.hs +++ b/src/Simplex/Chat/Mobile/WebRTC.hs @@ -1,14 +1,16 @@ {-# LANGUAGE FlexibleContexts #-} -module Simplex.Chat.Mobile.WebRTC ( - cChatEncryptMedia, - cChatDecryptMedia, - chatEncryptMedia, - chatDecryptMedia, - reservedSize, -) where +module Simplex.Chat.Mobile.WebRTC + ( cChatEncryptMedia, + cChatDecryptMedia, + chatEncryptMedia, + chatDecryptMedia, + reservedSize, + ) where +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import qualified Crypto.Cipher.Types as AES import Data.Bifunctor (bimap) import qualified Data.ByteArray as BA @@ -19,8 +21,8 @@ import Data.Either (fromLeft) import Data.Word (Word8) import Foreign.C (CInt, CString, newCAString) import Foreign.Ptr (Ptr) -import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Mobile.Shared +import qualified Simplex.Messaging.Crypto as C cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString cChatEncryptMedia = cTransformMedia chatEncryptMedia diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 7ce6305d20..f8cab1e357 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -42,6 +42,7 @@ data ChatOpts = ChatOpts allowInstantFiles :: Bool, autoAcceptFileSize :: Integer, muteNotifications :: Bool, + markRead :: Bool, maintenance :: Bool } @@ -206,7 +207,6 @@ chatOptsP appDir defaultDbFileName = do optional $ strOption ( long "device-name" - <> short 'e' <> metavar "DEVICE" <> help "Device name to use in connections with remote hosts and controller" ) @@ -269,6 +269,12 @@ chatOptsP appDir defaultDbFileName = do ( long "mute" <> help "Mute notifications" ) + markRead <- + switch + ( long "mark-read" + <> short 'r' + <> help "Mark shown messages as read" + ) maintenance <- switch ( long "maintenance" @@ -287,6 +293,7 @@ chatOptsP appDir defaultDbFileName = do allowInstantFiles, autoAcceptFileSize, muteNotifications, + markRead, maintenance } diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index 55a051ad80..95f5f16207 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -18,10 +18,10 @@ generateRandomProfile = do pickNoun adjective n | n == 0 = pick nouns | otherwise = do - noun <- pick nouns - if noun == adjective - then pickNoun adjective (n - 1) - else pure noun + noun <- pick nouns + if noun == adjective + then pickNoun adjective (n - 1) + else pure noun adjectives :: [Text] adjectives = diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2040941aa4..3de7c03e86 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -13,6 +13,7 @@ {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Protocol where diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index e2137b35a2..b9989d8af1 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -26,13 +26,14 @@ 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 Data.List.NonEmpty (NonEmpty, nonEmpty) +import qualified Data.List.NonEmpty as L 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 Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC @@ -97,24 +98,26 @@ discoveryTimeout = 60000000 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 + 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 + 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' @@ -133,8 +136,8 @@ setNewRemoteHostId sseq rhId = do where err = pure . Left . ChatErrorRemoteHost RHNew -startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) -startRemoteHost rh_ = do +startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> Maybe RCCtrlAddress -> Maybe Word16 -> m (NonEmpty RCCtrlAddress, Maybe RemoteHostInfo, RCSignedInvitation) +startRemoteHost rh_ rcAddrPrefs_ port_ = do (rhKey, multicast, remoteHost_, pairing) <- case rh_ of Just (rhId, multicast) -> do rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId @@ -142,19 +145,20 @@ startRemoteHost rh_ = do 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 + (localAddrs, invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast rcAddrPrefs_ port_ + let rcAddr_ = L.head localAddrs <$ rcAddrPrefs_ cmdOk <- newEmptyTMVarIO rhsWaitSession <- async $ do rhKeyVar <- newTVarIO rhKey atomically $ takeTMVar cmdOk - handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars + handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rcAddr_ 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 ()) + (localAddrs, remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) where mkCtrlAppInfo = do deviceName <- chatReadVar localDeviceName @@ -167,16 +171,18 @@ startRemoteHost rh_ = do 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, RHSRConnectionFailed err)) rhKey - throwError err + handleConnectError rhKey sessSeq action = + action `catchChatError` \err -> do + logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err + cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) 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, RHSRCrashed err)) - waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () - waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do + handleHostError sessSeq rhKeyVar action = + action `catchChatError` \err -> do + logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err + readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) + waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () + waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars = do (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars let sessionCode = verificationCode sessId withRemoteHostSession rhKey sseq $ \case @@ -190,7 +196,7 @@ startRemoteHost rh_ = do 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} + rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' rcAddr_ hostDeviceName sseq RHSConfirmed {sessionCode} let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew when (rhKey' /= rhKey) $ do atomically $ writeTVar rhKeyVar rhKey' @@ -205,17 +211,17 @@ startRemoteHost rh_ = do _ -> 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 + upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo + upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ rcAddr_ 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 + rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath rcAddr_ port_ pairing' >>= getRemoteHost db setNewRemoteHostId sseq remoteHostId pure $ remoteHostInfo rh $ Just state Just rhi@RemoteHostInfo {remoteHostId} -> do - withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' + withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' rcAddr_ port_ pure (rhi :: RemoteHostInfo) {sessionState = Just state} onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () onDisconnected rhKey sseq = do @@ -250,14 +256,15 @@ cancelRemoteHostSession :: ChatMonad m => Maybe (SessionSeq, RemoteHostStopReaso cancelRemoteHostSession handlerInfo_ rhKey = do sessions <- asks remoteHostSessions crh <- asks currentRemoteHost - deregistered <- atomically $ - TM.lookup rhKey sessions >>= \case - Nothing -> pure Nothing - Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> 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 + deregistered <- + atomically $ + TM.lookup rhKey sessions >>= \case + Nothing -> pure Nothing + Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> 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) forM_ (snd <$> handlerInfo_) $ \rhStopReason -> @@ -312,8 +319,8 @@ switchRemoteHost rhId_ = do rhi_ <$ chatWriteVar currentRemoteHost rhId_ remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo -remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = - RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} +remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_} sessionState = + RemoteHostInfo {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_, sessionState} deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost rhId = do @@ -401,9 +408,10 @@ findKnownRemoteCtrl = do (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) - rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case - Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" - Just rc -> pure rc + rc <- + withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case + Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" + Just rc -> pure rc atomically $ putTMVar foundCtrl (rc, inv) let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} @@ -422,7 +430,7 @@ confirmRemoteCtrl rcId = do pure $ Right (sseq, action, foundCtrl) _ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState uninterruptibleCancel listener - (RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found + (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" @@ -647,10 +655,12 @@ handleCtrlError sseq mkReason name action = cancelActiveRemoteCtrl :: ChatMonad m => Maybe (SessionSeq, RemoteCtrlStopReason) -> m () cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do var <- asks remoteCtrlSession - session_ <- atomically $ readTVar var >>= \case - Nothing -> pure Nothing - Just (oldSeq, _) | maybe False (/= oldSeq) (fst <$> handlerInfo_) -> pure Nothing - Just (_, s) -> Just s <$ writeTVar var Nothing + session_ <- + atomically $ + readTVar var >>= \case + Nothing -> pure Nothing + Just (oldSeq, _) | (maybe False ((oldSeq /=) . fst) handlerInfo_) -> pure Nothing + Just (_, s) -> Just s <$ writeTVar var Nothing forM_ session_ $ \session -> do liftIO $ cancelRemoteCtrl handlingError session forM_ (snd <$> handlerInfo_) $ \rcStopReason -> diff --git a/src/Simplex/Chat/Remote/AppVersion.hs b/src/Simplex/Chat/Remote/AppVersion.hs index e39a64b0a3..ad9f16e1be 100644 --- a/src/Simplex/Chat/Remote/AppVersion.hs +++ b/src/Simplex/Chat/Remote/AppVersion.hs @@ -11,7 +11,7 @@ module Simplex.Chat.Remote.AppVersion compatibleAppVersion, isAppCompatible, ) - where +where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J diff --git a/src/Simplex/Chat/Remote/Multicast.hsc b/src/Simplex/Chat/Remote/Multicast.hsc index 3919b4423f..2303bd970d 100644 --- a/src/Simplex/Chat/Remote/Multicast.hsc +++ b/src/Simplex/Chat/Remote/Multicast.hsc @@ -6,10 +6,8 @@ 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. --} +-- | 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 diff --git a/src/Simplex/Chat/Remote/Protocol.hs b/src/Simplex/Chat/Remote/Protocol.hs index c1acee1e0f..af4c7d33ec 100644 --- a/src/Simplex/Chat/Remote/Protocol.hs +++ b/src/Simplex/Chat/Remote/Protocol.hs @@ -6,8 +6,8 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Remote.Protocol where @@ -41,16 +41,16 @@ 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.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 Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode) import System.FilePath (takeFileName, ()) import UnliftIO @@ -64,10 +64,10 @@ data RemoteCommand data RemoteResponse = RRChatResponse {chatResponse :: ChatResponse} - | RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout + | 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 + | RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- The protocol error happened on the server side deriving (Show) -- Force platform-independent encoding as the types aren't UI-visible @@ -126,7 +126,7 @@ remoteStoreFile c localPath fileName = do r -> badResponse r remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () -remoteGetFile c@RemoteHostClient{encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} = +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 @@ -140,7 +140,7 @@ 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_ + 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 diff --git a/src/Simplex/Chat/Remote/Transport.hs b/src/Simplex/Chat/Remote/Transport.hs index c5ddfbdb8f..ccd10b328a 100644 --- a/src/Simplex/Chat/Remote/Transport.hs +++ b/src/Simplex/Chat/Remote/Transport.hs @@ -5,15 +5,15 @@ module Simplex.Chat.Remote.Transport where import Control.Monad import Control.Monad.Except -import Data.ByteString.Builder (Builder, byteString) import Data.ByteString (ByteString) +import Data.ByteString.Builder (Builder, byteString) import qualified Data.ByteString.Lazy as LB import Data.Word (Word32) -import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Chat.Remote.Types +import Simplex.FileTransfer.Description (FileDigest (..)) +import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile) 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 (..)) diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index 783a083e55..d85dde9e87 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -18,16 +18,17 @@ import qualified Data.Aeson.TH as J import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) +import Data.Word (Word16) import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Types (verificationCode) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Simplex.Messaging.Transport (TLS (..)) 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, @@ -48,13 +49,13 @@ data RemoteCrypto = RemoteCrypto data RemoteSignatures = RSSign - { idPrivKey :: C.PrivateKeyEd25519, - sessPrivKey :: C.PrivateKeyEd25519 - } + { idPrivKey :: C.PrivateKeyEd25519, + sessPrivKey :: C.PrivateKeyEd25519 + } | RSVerify - { idPubKey :: C.PublicKeyEd25519, - sessPubKey :: C.PublicKeyEd25519 - } + { idPubKey :: C.PublicKeyEd25519, + sessPubKey :: C.PublicKeyEd25519 + } type SessionSeq = Int @@ -71,12 +72,12 @@ data RemoteHostSession | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConnected - { rchClient :: RCHostClient, - tls :: TLS, - rhClient :: RemoteHostClient, - pollAction :: Async (), - storePath :: FilePath - } + { rchClient :: RCHostClient, + tls :: TLS, + rhClient :: RemoteHostClient, + pollAction :: Async (), + storePath :: FilePath + } data RemoteHostSessionState = RHSStarting @@ -128,6 +129,8 @@ data RemoteHost = RemoteHost { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, + bindAddress_ :: Maybe RCCtrlAddress, + bindPort_ :: Maybe Word16, hostPairing :: RCHostPairing } @@ -136,6 +139,8 @@ data RemoteHostInfo = RemoteHostInfo { remoteHostId :: RemoteHostId, hostDeviceName :: Text, storePath :: FilePath, + bindAddress_ :: Maybe RCCtrlAddress, + bindPort_ :: Maybe Word16, sessionState :: Maybe RemoteHostSessionState } deriving (Show) @@ -158,6 +163,7 @@ data PlatformEncoding deriving (Show, Eq) localEncoding :: PlatformEncoding + #if defined(darwin_HOST_OS) && defined(swiftJSON) localEncoding = PESwift #else diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index e710a1d599..a72b23886f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -4,6 +4,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Connections ( getConnectionEntity, @@ -15,6 +16,7 @@ module Simplex.Chat.Store.Connections where import Control.Applicative ((<|>)) +import Control.Monad import Control.Monad.Except import Data.Int (Int64) import Data.Maybe (catMaybes, fromMaybe) @@ -22,11 +24,11 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime (..)) import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Protocol import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) @@ -154,8 +156,9 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do - connId_ <- maybeFirstRow fromOnly $ - DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) + connId_ <- + maybeFirstRow fromOnly $ + DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ -- search connection for connection plan: @@ -164,21 +167,22 @@ getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = -- deleted connections are filtered out to allow re-connecting via same contact address getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do - connId_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT agent_conn_id FROM ( - SELECT - agent_conn_id, - (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord - FROM connections - WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? - ORDER BY conn_ord DESC, created_at DESC - LIMIT 1 - ) - |] - (userId, cReqHash1, cReqHash2, ConnDeleted) + connId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) + |] + (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index d733d536b3..1b3a5285d6 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -6,6 +6,7 @@ {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Direct ( updateContact_, @@ -65,7 +66,9 @@ module Simplex.Chat.Store.Direct ) where +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import Data.Either (rights) import Data.Functor (($>)) import Data.Int (Int64) @@ -305,14 +308,14 @@ deleteUnusedProfile_ db userId profileId = updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact updateContactProfile db user@User {userId} c p' | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure c {profile, mergedPreferences} + liftIO $ updateContactProfile_ db userId profileId p' + pure c {profile, mergedPreferences} | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs - updateContact_ db userId contactId localDisplayName ldn currentTs - pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId p' currentTs + updateContact_ db userId contactId localDisplayName ldn currentTs + pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} where Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Profile {displayName = newName, preferences} = p' @@ -779,10 +782,8 @@ updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO () updateConnectionStatus db Connection {connId} connStatus = do currentTs <- getCurrentTime if connStatus == ConnReady - then - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) - else - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) + then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + else DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = @@ -811,4 +812,3 @@ resetContactConnInitiated db User {userId} Connection {connId} = do WHERE user_id = ? AND connection_id = ? |] (updatedAt, userId, connId) - diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 996a44b06f..41d0733db1 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -77,7 +77,9 @@ module Simplex.Chat.Store.Files where import Control.Applicative ((<|>)) +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import Data.Either (rights) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) @@ -106,7 +108,7 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do - cutoffTs <- addUTCTime (- week) <$> getCurrentTime + cutoffTs <- addUTCTime (-week) <$> getCurrentTime fileIds :: [Int64] <- map fromOnly <$> DB.query @@ -129,7 +131,7 @@ getLiveSndFileTransfers db User {userId} = do getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer] getLiveRcvFileTransfers db user@User {userId} = do - cutoffTs <- addUTCTime (- week) <$> getCurrentTime + cutoffTs <- addUTCTime (-week) <$> getCurrentTime fileIds :: [Int64] <- map fromOnly <$> DB.query @@ -231,11 +233,12 @@ createSndGroupInlineFT db GroupMember {groupMemberId, localDisplayName = n} Conn updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> ExceptT StoreError IO () updateSndDirectFTDelivery _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = liftIO $ - DB.execute - db - "UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_inline IS NOT NULL" - (msgDeliveryId, connId, fileId) +updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = + liftIO $ + DB.execute + db + "UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_inline IS NOT NULL" + (msgDeliveryId, connId, fileId) updateSndGroupFTDelivery :: DB.Connection -> GroupMember -> Connection -> FileTransferMeta -> Int64 -> IO () updateSndGroupFTDelivery db GroupMember {groupMemberId} Connection {connId} FileTransferMeta {fileId} msgDeliveryId = @@ -721,7 +724,7 @@ removeFileCryptoArgs db fileId = do getRcvFilesToReceive :: DB.Connection -> User -> IO [RcvFileTransfer] getRcvFilesToReceive db user@User {userId} = do - cutoffTs <- addUTCTime (- (2 * nominalDay)) <$> getCurrentTime + cutoffTs <- addUTCTime (-(2 * nominalDay)) <$> getCurrentTime fileIds :: [Int64] <- map fromOnly <$> DB.query @@ -765,20 +768,20 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation { pure $ case map fromOnly ns of [] | chunkNo == 1 -> - if chunkSize >= fileSize - then RcvChunkFinal - else RcvChunkOk + if chunkSize >= fileSize + then RcvChunkFinal + else RcvChunkOk | otherwise -> RcvChunkError n : _ | chunkNo == n -> RcvChunkDuplicate | chunkNo == n + 1 -> - let prevSize = n * chunkSize - in if prevSize >= fileSize - then RcvChunkError - else - if prevSize + chunkSize >= fileSize - then RcvChunkFinal - else RcvChunkOk + let prevSize = n * chunkSize + in if prevSize >= fileSize + then RcvChunkError + else + if prevSize + chunkSize >= fileSize + then RcvChunkFinal + else RcvChunkOk | otherwise -> RcvChunkError updatedRcvFileChunkStored :: DB.Connection -> RcvFileTransfer -> Integer -> IO () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index d71ea80c82..a3dc5d815f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -8,6 +8,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Groups ( -- * Util methods @@ -112,12 +113,14 @@ module Simplex.Chat.Store.Groups ) where +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) import Data.List (partition, sortOn) -import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) @@ -441,39 +444,39 @@ createGroupInvitedViaLink void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs supportedChatVRange liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId - where - insertGroup_ currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile - withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do - liftIO $ do - DB.execute - db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) - profileId <- insertedRowId db - DB.execute - db - "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" - (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) - insertedRowId db - insertHost_ currentTs groupId = ExceptT $ do - let fromMemberProfile = profileFromName fromMemberName - withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do - (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs - let MemberIdRole {memberId, memberRole} = fromMember - liftIO $ do - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - ) - insertedRowId db + where + insertGroup_ currentTs = ExceptT $ do + let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" + (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + insertedRowId db + insertHost_ currentTs groupId = ExceptT $ do + let fromMemberProfile = profileFromName fromMemberName + withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do + (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + let MemberIdRole {memberId, memberRole} = fromMember + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkHash db groupId connId = @@ -809,22 +812,22 @@ createAcceptedMember insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) - where - JVersionRange (VersionRange minV maxV) = cReqChatVRange - insertMember_ memberId createdAt = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) - :. (minV, maxV) - ) + where + JVersionRange (VersionRange minV maxV) = cReqChatVRange + insertMember_ memberId createdAt = + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + :. (minV, maxV) + ) createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () createAcceptedMemberConnection @@ -952,23 +955,24 @@ createNewMember_ :. (minV, maxV) ) groupMemberId <- insertedRowId db - pure GroupMember { - groupMemberId, - groupId, - memberId, - memberRole, - memberCategory, - memberStatus, - memberSettings = defaultMemberSettings, - invitedBy, - invitedByGroupMemberId = memInvitedByGroupMemberId, - localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "", - memberContactId, - memberContactProfileId, - activeConn, - memberChatVRange = JVersionRange mcvr - } + pure + GroupMember + { groupMemberId, + groupId, + memberId, + memberRole, + memberCategory, + memberStatus, + memberSettings = defaultMemberSettings, + invitedBy, + invitedByGroupMemberId = memInvitedByGroupMemberId, + localDisplayName, + memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberContactId, + memberContactProfileId, + activeConn, + memberChatVRange = JVersionRange mcvr + } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -1099,41 +1103,41 @@ getForwardIntroducedMembers :: DB.Connection -> User -> GroupMember -> Bool -> I getForwardIntroducedMembers db user invitee highlyAvailable = do memberIds <- map fromOnly <$> query filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds - where - mId = groupMemberId' invitee - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) - q = - [sql| - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] + where + mId = groupMemberId' invitee + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT re_group_member_id + FROM group_member_intros + WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardInvitedMembers db user forwardMember highlyAvailable = do memberIds <- map fromOnly <$> query filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds - where - mId = groupMemberId' forwardMember - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) - q = - [sql| - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] + where + mId = groupMemberId' forwardMember + query + | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) + | otherwise = + DB.query + db + (q <> " AND intro_chat_protocol_version >= ?") + (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) + q = + [sql| + SELECT to_group_member_id + FROM group_member_intros + WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) + |] createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -1258,15 +1262,15 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} | displayName == newName = liftIO $ do - currentTs <- getCurrentTime - updateGroupProfile_ currentTs - pure (g :: GroupInfo) {groupProfile = p', fullGroupPreferences} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime updateGroupProfile_ currentTs - updateGroup_ ldn currentTs - pure $ Right (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences} + pure (g :: GroupInfo) {groupProfile = p', fullGroupPreferences} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateGroupProfile_ currentTs + updateGroup_ ldn currentTs + pure $ Right (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences} where fullGroupPreferences = mergeGroupPreferences groupPreferences updateGroupProfile_ currentTs = @@ -1312,31 +1316,33 @@ getGroupInfo db User {userId, userContactId} groupId = getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do - groupId_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT group_id - FROM user_contact_links - WHERE user_id = ? AND conn_req_contact IN (?,?) - |] - (userId, cReqSchema1, cReqSchema2) + groupId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) + |] + (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do - groupId_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT g.group_id - FROM groups g - JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) - AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) - LIMIT 1 - |] - (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + groupId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) + LIMIT 1 + |] + (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId @@ -1930,18 +1936,18 @@ createMemberContactConn_ updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember updateMemberProfile db User {userId} m p' | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure m {memberProfile = profile} + liftIO $ updateContactProfile_ db userId profileId p' + pure m {memberProfile = profile} | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs - DB.execute - db - "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" - (ldn, currentTs, userId, groupMemberId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) - pure $ Right m {localDisplayName = ldn, memberProfile = profile} + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId p' currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + pure $ Right m {localDisplayName = ldn, memberProfile = profile} where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 8e1684259d..102612b4ee 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -10,6 +10,7 @@ {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Messages ( getContactConnIds_, @@ -100,7 +101,9 @@ module Simplex.Chat.Store.Messages ) where +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) @@ -195,40 +198,41 @@ createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMs createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs pure msg -createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewMessage{chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = +createNewRcvMessage :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage +createNewRcvMessage db connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of - Just sharedMsgId -> liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case - Just (duplAuthorId, duplFwdMemberId) -> - throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId - Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId + Just sharedMsgId -> + liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case + Just (duplAuthorId, duplFwdMemberId) -> + throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId + Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId - where - duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId)) - duplicateGroupMsgMemberIds groupId sharedMsgId = - maybeFirstRow id - $ DB.query + where + duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId)) + duplicateGroupMsgMemberIds groupId sharedMsgId = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT author_group_member_id, forwarded_by_group_member_id + FROM messages + WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + |] + (groupId, sharedMsgId) + insertRcvMsg connId_ groupId_ = do + currentTs <- getCurrentTime + DB.execute db [sql| - SELECT author_group_member_id, forwarded_by_group_member_id - FROM messages - WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 + INSERT INTO messages + (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (groupId, sharedMsgId) - insertRcvMsg connId_ groupId_ = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?) - |] - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) - msgId <- insertedRowId db - pure RcvMessage{msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) + msgId <- insertedRowId db + pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do @@ -524,12 +528,12 @@ getDirectChatPreviews_ db user@User {userId} = do JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id LEFT JOIN ( - SELECT contact_id, MAX(chat_item_id) AS MaxId + SELECT contact_id, chat_item_id, MAX(created_at) FROM chat_items GROUP BY contact_id - ) MaxIds ON MaxIds.contact_id = ct.contact_id - LEFT JOIN chat_items i ON i.contact_id = MaxIds.contact_id - AND i.chat_item_id = MaxIds.MaxId + ) LastItems ON LastItems.contact_id = ct.contact_id + LEFT JOIN chat_items i ON i.contact_id = LastItems.contact_id + AND i.chat_item_id = LastItems.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread @@ -611,12 +615,12 @@ getGroupChatPreviews_ db User {userId, userContactId} = do JOIN group_members mu ON mu.group_id = g.group_id JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) LEFT JOIN ( - SELECT group_id, MAX(chat_item_id) AS MaxId + SELECT group_id, chat_item_id, MAX(item_ts) FROM chat_items GROUP BY group_id - ) MaxIds ON MaxIds.group_id = g.group_id - LEFT JOIN chat_items i ON i.group_id = MaxIds.group_id - AND i.chat_item_id = MaxIds.MaxId + ) LastItems ON LastItems.group_id = g.group_id + LEFT JOIN chat_items i ON i.group_id = LastItems.group_id + AND i.chat_item_id = LastItems.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread @@ -720,7 +724,7 @@ getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' - ORDER BY i.chat_item_id DESC + ORDER BY i.created_at DESC, i.chat_item_id DESC LIMIT ? |] (userId, contactId, search, count) @@ -750,7 +754,7 @@ getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId coun LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' AND i.chat_item_id > ? - ORDER BY i.chat_item_id ASC + ORDER BY i.created_at ASC, i.chat_item_id ASC LIMIT ? |] (userId, contactId, search, afterChatItemId, count) @@ -780,7 +784,7 @@ getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId co LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%' AND i.chat_item_id < ? - ORDER BY i.chat_item_id DESC + ORDER BY i.created_at DESC, i.chat_item_id DESC LIMIT ? |] (userId, contactId, search, beforeChatItemId, count) @@ -1798,22 +1802,22 @@ getDirectReactions db ct itemSharedMId sent = setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs | add = - DB.execute - db - [sql| - INSERT INTO chat_item_reactions - (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) - VALUES (?,?,?,?,?,?) - |] - (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) + DB.execute + db + [sql| + INSERT INTO chat_item_reactions + (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?) + |] + (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) | otherwise = - DB.execute - db - [sql| - DELETE FROM chat_item_reactions - WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? - |] - (contactId' ct, itemSharedMId, sent, reaction) + DB.execute + db + [sql| + DELETE FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? + |] + (contactId' ct, itemSharedMId, sent, reaction) getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = @@ -1830,22 +1834,22 @@ getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs | add = - DB.execute - db - [sql| - INSERT INTO chat_item_reactions - (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) - VALUES (?,?,?,?,?,?,?,?) - |] - (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) + DB.execute + db + [sql| + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?,?,?) + |] + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) | otherwise = - DB.execute - db - [sql| - DELETE FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? - |] - (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction) + DB.execute + db + [sql| + DELETE FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + |] + (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction) getTimedItems :: DB.Connection -> User -> UTCTime -> IO [((ChatRef, ChatItemId), UTCTime)] getTimedItems db User {userId} startTimedThreadCutoff = diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 7b9ead1b10..31d0525dba 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -90,6 +90,7 @@ 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.Chat.Migrations.M20231126_remote_ctrl_address import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -179,7 +180,8 @@ schemaMigrations = ("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), - ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) + ("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control), + ("20231126_remote_ctrl_address", m20231126_remote_ctrl_address, Just down_m20231126_remote_ctrl_address) ] -- | 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 fbc0930628..7ce48db4a2 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -8,6 +8,7 @@ {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Profiles ( AutoAccept (..), @@ -58,14 +59,15 @@ module Simplex.Chat.Store.Profiles ) where +import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import qualified Data.Aeson.TH as J import Data.Functor (($>)) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) -import Data.List.NonEmpty (NonEmpty) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) @@ -86,7 +88,7 @@ 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) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime @@ -245,19 +247,19 @@ updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOv updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure user {profile, fullPreferences} + liftIO $ updateContactProfile_ db userId profileId p' + pure user {profile, fullPreferences} | otherwise = - checkConstraint SEDuplicateName . liftIO $ do - currentTs <- getCurrentTime - DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) - DB.execute - db - "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId p' currentTs - updateContact_ db userId userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences} + checkConstraint SEDuplicateName . liftIO $ do + currentTs <- getCurrentTime + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + DB.execute + db + "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (newName, newName, userId, currentTs, currentTs) + updateContactProfile_' db userId profileId p' currentTs + updateContact_ db userId userContactId localDisplayName newName currentTs + pure user {localDisplayName = newName, profile, fullPreferences} where User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} = user Profile {displayName = newName, preferences} = p' @@ -454,17 +456,18 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do - ctId_ <- maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT ct.contact_id - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL - |] - (userId, cReqSchema1, cReqSchema2) + ctId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL + |] + (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_ updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index ec84860379..a88d87a04e 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -8,6 +8,8 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8, decodeASCII) +import Data.Word (Word16) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) @@ -16,11 +18,12 @@ 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.Messaging.Encoding.String (StrEncoding (..)) 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 +insertRemoteHost :: DB.Connection -> Text -> FilePath -> Maybe RCCtrlAddress -> Maybe Word16 -> RCHostPairing -> ExceptT StoreError IO RemoteHostId +insertRemoteHost db hostDeviceName storePath rcAddr_ bindPort_ RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do KnownHostPairing {hostFingerprint, hostDhPubKey} <- maybe (throwError SERemoteHostUnknown) pure kh_ checkConstraint SERemoteHostDuplicateCA . liftIO $ @@ -28,12 +31,14 @@ insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPri db [sql| INSERT INTO remote_hosts - (host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) + (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) VALUES - (?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |] - (hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) + (hostDeviceName, storePath, bindAddr_, bindIface_, bindPort_, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey) liftIO $ insertedRowId db + where + (bindAddr_, bindIface_) = rcCtrlAddressFields_ rcAddr_ getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts db = @@ -52,27 +57,34 @@ getRemoteHostByFingerprint db 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 + SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub, bind_iface, bind_addr, bind_port 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} +toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519, Maybe Text, Maybe Text, Maybe Word16) -> RemoteHost +toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey, ifaceName_, ifaceAddr_, bindPort_) = + RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing, bindAddress_, bindPort_} where hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} + bindAddress_ = RCCtrlAddress <$> (decodeAddr <$> ifaceAddr_) <*> ifaceName_ + decodeAddr = either (error "Error parsing TransportHost") id . strDecode . encodeUtf8 -updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> IO () -updateHostPairing db rhId hostDeviceName hostDhPubKey = +updateHostPairing :: DB.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> Maybe RCCtrlAddress -> Maybe Word16 -> IO () +updateHostPairing db rhId hostDeviceName hostDhPubKey rcAddr_ bindPort_ = DB.execute db [sql| UPDATE remote_hosts - SET host_device_name = ?, host_dh_pub = ? + SET host_device_name = ?, host_dh_pub = ?, bind_addr = ?, bind_iface = ?, bind_port = ? WHERE remote_host_id = ? |] - (hostDeviceName, hostDhPubKey, rhId) + (hostDeviceName, hostDhPubKey, bindAddr_, bindIface_, bindPort_, rhId) + where + (bindAddr_, bindIface_) = rcCtrlAddressFields_ rcAddr_ + +rcCtrlAddressFields_ :: Maybe RCCtrlAddress -> (Maybe Text, Maybe Text) +rcCtrlAddressFields_ = maybe (Nothing, Nothing) $ \RCCtrlAddress {address, interface} -> (Just . decodeASCII $ strEncode address, Just interface) deleteRemoteHostRecord :: DB.Connection -> RemoteHostId -> IO () deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 9d2da138b7..93c3ab197c 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -12,7 +12,9 @@ module Simplex.Chat.Store.Shared where import Control.Exception (Exception) import qualified Control.Exception as E +import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG, randomBytesGenerate) import qualified Data.Aeson.TH as J import qualified Data.ByteString.Base64 as B64 @@ -99,7 +101,7 @@ data StoreError | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId} | SERemoteHostNotFound {remoteHostId :: RemoteHostId} - | SERemoteHostUnknown -- ^ attempting to store KnownHost without a known fingerprint + | SERemoteHostUnknown -- attempting to store KnownHost without a known fingerprint | SERemoteHostDuplicateCA | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index afc0ee4c30..c27675678e 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -5,7 +5,7 @@ module Simplex.Chat.Terminal where import Control.Exception (handle, throwIO) -import Control.Monad.Except +import Control.Monad import qualified Data.List.NonEmpty as L import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB @@ -44,7 +44,7 @@ simplexChatTerminal cfg opts t = handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u - runChatTerminal ct cc + runChatTerminal ct cc opts checkDBKeyError :: SQLError -> IO () checkDBKeyError e = case sqlError e of @@ -53,5 +53,5 @@ checkDBKeyError e = case sqlError e of exitFailure _ -> throwIO e -runChatTerminal :: ChatTerminal -> ChatController -> IO () -runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] +runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO () +runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index eea693da42..7b96abc1ce 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Terminal.Input where import Control.Applicative (optional, (<|>)) import Control.Concurrent (forkFinally, forkIO, killThread, mkWeakThreadId, threadDelay) +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import qualified Data.Attoparsec.ByteString.Char8 as A @@ -193,19 +194,19 @@ receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatSt case lm_ of Just LiveMessage {chatName} | live -> do - writeTVar termState ts' {previousInput} - writeTBQueue inputQ $ "/live " <> chatNameStr chatName + writeTVar termState ts' {previousInput} + writeTBQueue inputQ $ "/live " <> chatNameStr chatName | otherwise -> - writeTVar termState ts' {inputPrompt = "> ", previousInput} + writeTVar termState ts' {inputPrompt = "> ", previousInput} where previousInput = chatNameStr chatName <> " " <> s _ | live -> when (isSend s) $ do - writeTVar termState ts' {previousInput = s} - writeTBQueue inputQ $ "/live " <> s + writeTVar termState ts' {previousInput = s} + writeTBQueue inputQ $ "/live " <> s | otherwise -> do - writeTVar termState ts' {inputPrompt = "> ", previousInput = s} - writeTBQueue inputQ s + writeTVar termState ts' {inputPrompt = "> ", previousInput = s} + writeTBQueue inputQ s pure $ (s,) <$> lm_ where isSend s = length s > 1 && (head s == '@' || head s == '#') @@ -342,9 +343,9 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr charsWithContact cs | live = cs | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = - chatPrefix <> cs + chatPrefix <> cs | (s == ">" || s == "\\" || s == "!") && cs == " " = - cs <> chatPrefix + cs <> chatPrefix | otherwise = cs insertChars = ts' . if p >= length s then append else insert append cs = let s' = s <> cs in (s', length s') @@ -380,13 +381,13 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr prevWordPos | p == 0 || null s = p | otherwise = - let before = take p s - beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before - in max 0 $ p - length before + length beforeWord + let before = take p s + beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before + in max 0 $ p - length before + length beforeWord nextWordPos | p >= length s || null s = p | otherwise = - let after = drop p s - afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after - in min (length s) $ p + length after - length afterWord + let after = drop p s + afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after + in min (length s) $ p + length after - length afterWord ts' (s', p') = ts {inputString = s', inputPosition = p', autoComplete = acp {acTabPressed = False}} diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 0adb4999a4..be8aa12cfe 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -24,7 +24,7 @@ import Simplex.Chat (execChatCommand, processChatCommand) import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) +import Simplex.Chat.Messages.CIContent (CIContent (..), SMsgDirection (..)) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Remote.Types (RHKey (..), RemoteHostId, RemoteHostInfo (..), RemoteHostSession (..)) @@ -142,13 +142,13 @@ withTermLock ChatTerminal {termLock} action = do action atomically $ putTMVar termLock () -runTerminalOutput :: ChatTerminal -> ChatController -> IO () -runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = do +runTerminalOutput :: ChatTerminal -> ChatController -> ChatOpts -> IO () +runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} ChatOpts {markRead} = do forever $ do (_, outputRH, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem u ci -> markChatItemRead u ci - CRChatItemUpdated u ci -> markChatItemRead u ci + CRNewChatItem u ci -> when markRead $ markChatItemRead u ci + CRChatItemUpdated u ci -> when markRead $ markChatItemRead u ci CRRemoteHostConnected {remoteHost = RemoteHostInfo {remoteHostId}} -> getRemoteUser remoteHostId CRRemoteHostStopped {remoteHostId_} -> mapM_ removeRemoteUser remoteHostId_ _ -> pure () @@ -167,9 +167,10 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s - getRemoteUser rhId = runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case - CRActiveUser {user} -> updateRemoteUser ct user rhId - cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr + getRemoteUser rhId = + runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case + CRActiveUser {user} -> updateRemoteUser ct user rhId + cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () @@ -326,9 +327,9 @@ updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessag clearLines from till | from >= till = return () | otherwise = do - setCursorPosition $ Position {row = from, col = 0} - eraseInLine EraseForward - clearLines (from + 1) till + setCursorPosition $ Position {row = from, col = 0} + eraseInLine EraseForward + clearLines (from + 1) till inputHeight :: TerminalState -> Int inputHeight ts = length (autoCompletePrefix ts <> inputPrompt ts <> inputString ts) `div` width + 1 autoCompletePrefix :: TerminalState -> String diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ddeee38d88..aaa455129b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -38,7 +38,7 @@ import qualified Data.Text as T import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (returnError, FromField(..)) +import Database.SQLite.Simple.FromField (FromField (..), returnError) import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) @@ -48,7 +48,7 @@ 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 (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -496,7 +496,7 @@ data LocalProfile = LocalProfile deriving (Eq, Show) localProfileId :: LocalProfile -> ProfileId -localProfileId = profileId +localProfileId LocalProfile {profileId} = profileId toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile toLocalProfile profileId Profile {displayName, fullName, image, contactLink, preferences} localAlias = diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index fffdd24b9e..0f41931acf 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -2,7 +2,7 @@ module Simplex.Chat.Types.Util where -import Data.Aeson (ToJSON, FromJSON) +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.ByteString (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB diff --git a/src/Simplex/Chat/Util.hs b/src/Simplex/Chat/Util.hs index 3ce663e69f..46b5be28b3 100644 --- a/src/Simplex/Chat/Util.hs +++ b/src/Simplex/Chat/Util.hs @@ -2,6 +2,7 @@ module Simplex.Chat.Util (week, encryptFile, chunkSize) where import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class import qualified Data.ByteString.Lazy as LB import Data.Time (NominalDiffTime) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 604c5ea548..545127b8d1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -13,8 +13,8 @@ module Simplex.Chat.View where 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.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) @@ -43,8 +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.AppVersion (AppVersion (..), pattern AppVersionRange) 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,6 +64,7 @@ import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow, tshow) import Simplex.Messaging.Version hiding (version) +import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Console.ANSI.Types type CurrentTime = UTCTime @@ -162,8 +163,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan - CRSentConfirmation u -> ttyUser u ["confirmation sent!"] - CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] + CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] @@ -273,7 +274,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRCallInvitations _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection u _ -> ttyUser u [] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] @@ -285,13 +285,13 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost_, invitation, ctrlPort} -> + CRRemoteHostStarted {remoteHost_, invitation, localAddrs = RCCtrlAddress {address} :| _, ctrlPort} -> [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_, "Remote session invitation:", plain invitation ] where - started = " started on port " <> ctrlPort + started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort CRRemoteHostSessionCode {remoteHost_, sessionCode} -> [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, "Compare session code with host:", @@ -307,10 +307,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> - [ "remote controller " <> sShow remoteCtrlId <> " found: " + [ ("remote controller " <> sShow remoteCtrlId <> " found: ") <> maybe (deviceName <> "not compatible") (\info -> viewRemoteCtrl info appVersion compatible) ctrlAppInfo_ ] - <> [ "use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] + <> ["use " <> highlight ("/confirm remote ctrl " <> show remoteCtrlId) <> " to connect" | isJust ctrlAppInfo_ && compatible] where deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> @@ -510,42 +510,43 @@ viewChats ts tz = concatMap chatPreview . reverse viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, content, quotedItem, file} doShow ts tz = - withGroupMsgForwarded . withItemDeleted <$> (case chat of - DirectChat c -> case chatDir of - CIDirectSnd -> case content of - CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc - CISndGroupEvent {} -> showSndItemProhibited to - _ -> showSndItem to - where - to = ttyToContact' c - CIDirectRcv -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta - CIRcvGroupEvent {} -> showRcvItemProhibited from - _ -> showRcvItem from - where - from = ttyFromContact c - where - quote = maybe [] (directQuote chatDir) quotedItem - GroupChat g -> case chatDir of - CIGroupSnd -> case content of - CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc - CISndGroupInvitation {} -> showSndItemProhibited to - _ -> showSndItem to - where - to = ttyToGroup g - CIGroupRcv m -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta - CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) quote meta [plainContent content] False - _ -> showRcvItem from - where - from = ttyFromGroup g m - where - quote = maybe [] (groupQuote g) quotedItem - _ -> []) + withGroupMsgForwarded . withItemDeleted <$> viewCI where + viewCI = case chat of + DirectChat c -> case chatDir of + CIDirectSnd -> case content of + CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc + CISndGroupEvent {} -> showSndItemProhibited to + _ -> showSndItem to + where + to = ttyToContact' c + CIDirectRcv -> case content of + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvGroupEvent {} -> showRcvItemProhibited from + _ -> showRcvItem from + where + from = ttyFromContact c + where + quote = maybe [] (directQuote chatDir) quotedItem + GroupChat g -> case chatDir of + CIGroupSnd -> case content of + CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc + CISndGroupInvitation {} -> showSndItemProhibited to + _ -> showSndItem to + where + to = ttyToGroup g + CIGroupRcv m -> case content of + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvGroupInvitation {} -> showRcvItemProhibited from + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) quote meta [plainContent content] False + _ -> showRcvItem from + where + from = ttyFromGroup g m + where + quote = maybe [] (groupQuote g) quotedItem + _ -> [] withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of Nothing -> item Just t -> item <> styled (colored Red) (" [" <> t <> "]") @@ -666,15 +667,15 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] | byUser = [plain $ "message " <> T.unpack (fromMaybe "deleted" deletedText_)] -- deletedText_ Nothing should be impossible here | otherwise = case chat of - DirectChat c -> case (chatDir, deletedContent) of - (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta + DirectChat c -> case (chatDir, deletedContent) of + (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta + _ -> prohibited + GroupChat g -> case ciMsgContent deletedContent of + Just mc -> + let m = chatItemMember g ci + in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta + _ -> prohibited _ -> prohibited - GroupChat g -> case ciMsgContent deletedContent of - Just mc -> - let m = chatItemMember g ci - in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta - _ -> prohibited - _ -> prohibited where deletedText_ :: Maybe Text deletedText_ = case toItem of @@ -786,7 +787,9 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of viewContactsList :: [Contact] -> [StyledString] viewContactsList = - let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) + let getLDN :: Contact -> ContactName + getLDN Contact {localDisplayName} = localDisplayName + ldn = T.toLower . getLDN in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn where muted' Contact {chatSettings, localDisplayName = ldn} @@ -820,8 +823,8 @@ simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simpl autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {acceptIncognito, autoReply} -> - ("auto_accept on" <> if acceptIncognito then ", incognito" else "") : - maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply + ("auto_accept on" <> if acceptIncognito then ", incognito" else "") + : maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply _ -> ["auto_accept off"] groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString] @@ -904,10 +907,10 @@ viewJoinedGroupMember g m = viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation g c role = - ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) : - case incognitoMembershipProfile g of - Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] - Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] + ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) + : case incognitoMembershipProfile g of + Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] + Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] groupPreserved :: GroupInfo -> [StyledString] groupPreserved g = ["use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group"] @@ -993,13 +996,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" - _ -> " (" <> memberCount <> - case enableNtfs of - MFAll -> ")" - MFNone -> ", muted, " <> unmute - MFMentions -> ", mentions only, " <> unmute + _ -> " (" <> memberCount <> viewNtf <> ")" where - unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" + viewNtf = case enableNtfs of + MFAll -> "" + MFNone -> ", muted, " <> unmute + MFMentions -> ", mentions only, " <> unmute + unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" @@ -1025,9 +1028,9 @@ viewContactsMerged c1 c2 ct' = viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] viewContactAndMemberAssociated ct g m ct' = - [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, - "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" - ] + [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" + ] viewUserProfile :: Profile -> [StyledString] viewUserProfile Profile {displayName, fullName} = @@ -1393,14 +1396,14 @@ viewContactUpdated Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName', contactLink = contactLink'}} | n == n' && fullName == fullName' && contactLink == contactLink' = [] | n == n' && fullName == fullName' = - if isNothing contactLink' - then [ttyContact n <> " removed contact address"] - else [ttyContact n <> " set new contact address, use " <> highlight ("/info " <> n) <> " to view"] + if isNothing contactLink' + then [ttyContact n <> " removed contact address"] + else [ttyContact n <> " set new contact address, use " <> highlight ("/info " <> n) <> " to view"] | n == n' = ["contact " <> ttyContact n <> fullNameUpdate] | otherwise = - [ "contact " <> ttyContact n <> " changed to " <> ttyFullName n' fullName', - "use " <> ttyToContact n' <> highlight' "" <> " to send messages" - ] + [ "contact " <> ttyContact n <> " changed to " <> ttyFullName n' fullName', + "use " <> ttyToContact n' <> highlight' "" <> " to send messages" + ] where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' @@ -1425,11 +1428,11 @@ receivedWithTime_ ts tz from quote CIMeta {itemId, itemTs, itemEdited, itemDelet live | itemEdited || isJust itemDeleted = "" | otherwise = case itemLive of - Just True - | updated -> ttyFrom "[LIVE] " - | otherwise -> ttyFrom "[LIVE started]" <> " use " <> highlight' ("/show [on/off/" <> show itemId <> "] ") - Just False -> ttyFrom "[LIVE ended] " - _ -> "" + Just True + | updated -> ttyFrom "[LIVE] " + | otherwise -> ttyFrom "[LIVE started]" <> " use " <> highlight' ("/show [on/off/" <> show itemId <> "] ") + Just False -> ttyFrom "[LIVE ended] " + _ -> "" ttyMsgTime :: CurrentTime -> TimeZone -> UTCTime -> StyledString ttyMsgTime now tz time = @@ -1455,9 +1458,9 @@ viewSentMessage to quote mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive live | itemEdited || isJust itemDeleted = "" | otherwise = case itemLive of - Just True -> ttyTo "[LIVE started] " - Just False -> ttyTo "[LIVE] " - _ -> "" + Just True -> ttyTo "[LIVE started] " + Just False -> ttyTo "[LIVE] " + _ -> "" viewSentBroadcast :: MsgContent -> Int -> Int -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] viewSentBroadcast mc s f ts tz time = prependFirst (highlight' "/feed" <> " (" <> sShow s <> failures <> ") " <> ttyMsgTime ts tz time <> " ") (ttyMsgContent mc) @@ -1548,11 +1551,12 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"] cfArgsStr _ = [] 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})) - ] + (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 @@ -1587,7 +1591,8 @@ viewFileTransferStatus (FTSnd FileTransferMeta {cancelled} fts@(ft : _), chunksN case concatMap recipientsTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of [recipientsStatus] -> ["sending " <> sndFile ft <> " " <> recipientsStatus] recipientsStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) recipientsStatuses - fs = fileStatus :: SndFileTransfer -> FileStatus + fs :: SndFileTransfer -> FileStatus + fs SndFileTransfer {fileStatus} = fileStatus recipientsTransferStatus [] = [] recipientsTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listRecipients ts] where @@ -1707,8 +1712,13 @@ viewRemoteHosts = \case [] -> ["No remote hosts"] hs -> "Remote hosts: " : map viewRemoteHostInfo hs where - viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = - plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState + viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState, bindAddress_, bindPort_} = + plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState <> ctrlBinds bindAddress_ bindPort_ + ctrlBinds Nothing Nothing = "" + ctrlBinds rca_ port_ = mconcat [" [", maybe "" rca rca_, maybe "" port port_, "]"] + where + rca RCCtrlAddress {interface, address} = interface <> " " <> decodeLatin1 (strEncode address) + port p = ":" <> tshow p viewSessionState = \case RHSStarting -> " (starting)" RHSConnecting _ -> " (connecting)" @@ -1759,9 +1769,10 @@ viewChatError logLevel testView = \case CEEmptyUserPassword _ -> ["user password is required"] CEUserAlreadyHidden _ -> ["user is already hidden"] CEUserNotHidden _ -> ["user is not hidden"] - CEInvalidDisplayName {displayName, validName} -> map plain $ - ["invalid display name: " <> viewName displayName] - <> ["you could use this one: " <> viewName validName | not (T.null validName)] + CEInvalidDisplayName {displayName, validName} -> + map plain $ + ["invalid display name: " <> viewName displayName] + <> ["you could use this one: " <> viewName validName | not (T.null validName)] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] diff --git a/stack.yaml b/stack.yaml deleted file mode 100644 index 52babb6dc8..0000000000 --- a/stack.yaml +++ /dev/null @@ -1,94 +0,0 @@ -# This file was automatically generated by 'stack init' -# -# Some commonly used options have been documented as comments in this file. -# For advanced use and comprehensive documentation of the format, please see: -# https://docs.haskellstack.org/en/stable/yaml_configuration/ - -# Resolver to choose a 'specific' stackage snapshot or a compiler version. -# A snapshot resolver dictates the compiler version and the set of packages -# to be used for project dependencies. For example: -# -# resolver: lts-3.5 -# resolver: nightly-2015-09-21 -# resolver: ghc-7.10.2 -# -# The location of a snapshot can be provided as a file or url. Stack assumes -# a snapshot provided as a file might change, whereas a url resource does not. -# -# resolver: ./custom-snapshot.yaml -# resolver: https://example.com/snapshots/2018-01-01.yaml -resolver: lts-18.21 - -# User packages to be built. -# Various formats can be used as shown in the example below. -# -# packages: -# - some-directory -# - https://example.com/foo/bar/baz-0.0.2.tar.gz -# subdirs: -# - auto-update -# - wai -packages: - - . -# Dependency packages to be pulled from upstream that are not in the resolver. -# These entries can reference officially published versions as well as -# forks / in-progress versions pinned to a git hash. For example: -# -extra-deps: - - cryptostore-0.2.1.0@sha256:9896e2984f36a1c8790f057fd5ce3da4cbcaf8aa73eb2d9277916886978c5b19,3881 - - network-3.1.2.7@sha256:e3d78b13db9512aeb106e44a334ab42b7aa48d26c097299084084cb8be5c5568,4888 - - simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079 - - tls-1.6.0@sha256:7ae39373fd2de27fb80e90f76d22aeeb9a074a0ddd120cbd02c9c52f516a9e55,6987 - # below hackage dependencies are to update Aeson to 2.0.3 - - OneTuple-0.3.1@sha256:a848c096c9d29e82ffdd30a9998aa2931cbccb3a1bc137539d80f6174d31603e,2262 - - attoparsec-0.14.4@sha256:79584bdada8b730cb5138fca8c35c76fbef75fc1d1e01e6b1d815a5ee9843191,5810 - - hashable-1.4.0.2@sha256:0cddd0229d1aac305ea0404409c0bbfab81f075817bd74b8b2929eff58333e55,5005 - - semialign-1.2.0.1@sha256:0e179b4d3a8eff79001d374d6c91917c6221696b9620f0a4d86852fc6a9b9501,2836 - - text-short-0.1.5@sha256:962c6228555debdc46f758d0317dea16e5240d01419b42966674b08a5c3d8fa6,3498 - - time-compat-1.9.6.1@sha256:42d8f2e08e965e1718917d54ad69e1d06bd4b87d66c41dc7410f59313dba4ed1,5033 - # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 - # - ../simplexmq - - github: simplex-chat/simplexmq - commit: 281bdebcb82aed4c8c2c08438b9cafc7908183a1 - - github: kazu-yamamoto/http2 - commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb - # - ../direct-sqlcipher - - github: simplex-chat/direct-sqlcipher - commit: 34309410eb2069b029b8fc1872deb1e0db123294 - # - ../sqlcipher-simple - - github: simplex-chat/sqlcipher-simple - commit: 5e154a2aeccc33ead6c243ec07195ab673137221 - # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - - github: simplex-chat/aeson - commit: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b - - github: simplex-chat/haskell-terminal - commit: f708b00009b54890172068f168bf98508ffcd495 -# -# extra-deps: [] - -# Override default flag values for local packages and extra-deps -flags: - zip: - disable-bzip2: true - disable-zstd: true - direct-sqlcipher: - openssl: true -# Extra package databases containing global packages -# extra-package-dbs: [] - -# Control whether we use the GHC we find on the path -# system-ghc: true -# -# Require a specific version of stack, using version ranges -# require-stack-version: -any # Default -# require-stack-version: ">=2.1" -# -# Override the architecture used by stack, especially useful on Windows -# arch: i386 -# arch: x86_64 -# -# Extra directories used by stack for building -# extra-lib-dirs: [/path/to/dir] -# -# Allow a newer minor version of GHC than the snapshot specifies -# compiler-check: newer-minor diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 8376a2f561..824e6be0a0 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -5,6 +5,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatClient where @@ -12,6 +13,7 @@ import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) +import Control.Monad import Control.Monad.Except import Data.Functor (($>)) import Data.List (dropWhileEnd, find) @@ -80,6 +82,7 @@ testOpts = allowInstantFiles = True, autoAcceptFileSize = 0, muteNotifications = True, + markRead = True, maintenance = False } @@ -172,7 +175,7 @@ startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts - chatAsync <- async . runSimplexChat opts user cc . const $ runChatTerminal ct + chatAsync <- async . runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ @@ -273,7 +276,7 @@ getTermLine cc = Just s -> do -- remove condition to always echo virtual terminal when (printOutput cc) $ do - -- when True $ do + -- when True $ do name <- userName cc putStrLn $ name <> ": " <> s pure s diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 1a133fd8e3..d7c8ff4586 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -259,7 +259,6 @@ testPlanInvitationLinkOk = bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection - alice <##> bob testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () @@ -283,7 +282,6 @@ testPlanInvitationLinkOwn tmp = alice ##> ("/_connect plan 1 " <> inv) alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection - alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] alice `send` "@alice_2 hi" alice @@ -1213,31 +1211,34 @@ testMuteGroup = cath `send` "> #team (hello) hello too!" cath <# "#team > bob hello" cath <## " hello too!" - concurrently_ - (bob > bob hello" - alice <## " hello too!" - ) + concurrentlyN_ + [ (bob > bob hello" + alice <## " hello too!" + ] bob ##> "/unmute mentions #team" bob <## "ok" alice `send` "> #team @bob (hello) hey bob!" alice <# "#team > bob hello" alice <## " hey bob!" - concurrently_ - ( do bob <# "#team alice> > bob hello" - bob <## " hey bob!" - ) - ( do cath <# "#team alice> > bob hello" - cath <## " hey bob!" - ) + concurrentlyN_ + [ do + bob <# "#team alice> > bob hello" + bob <## " hey bob!", + do + cath <# "#team alice> > bob hello" + cath <## " hey bob!" + ] alice `send` "> #team @cath (hello) hey cath!" alice <# "#team > cath hello too!" alice <## " hey cath!" - concurrently_ - (bob > cath hello too!" - cath <## " hey cath!" - ) + concurrentlyN_ + [ (bob > cath hello too!" + cath <## " hey cath!" + ] bob ##> "/gs" bob <## "#team (3 members, mentions only, you can /unmute #team)" bob ##> "/unmute #team" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index db70f9212a..4396a900dc 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatTests.Files where diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 03ddf7d57b..8686310244 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -7,7 +7,7 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (when, void) +import Control.Monad (void, when) import qualified Data.ByteString as B import Data.List (isInfixOf) import qualified Data.Text as T @@ -122,7 +122,8 @@ chatGroupTests = do -- because host uses current code and sends version in MemberInfo testNoDirect vrMem2 vrMem3 noConns = it - ( "host " <> vRangeStr supportedChatVRange + ( "host " + <> vRangeStr supportedChatVRange <> (", 2nd mem " <> vRangeStr vrMem2) <> (", 3rd mem " <> vRangeStr vrMem3) <> (if noConns then " : 2 3" else " : 2 <##> 3") @@ -3859,11 +3860,9 @@ testMemberContactProfileUpdate = bob #> "#team hello too" alice <# "#team rob> hello too" cath <# "#team bob> hello too" -- not updated profile - cath #> "#team hello there" alice <# "#team kate> hello there" bob <# "#team cath> hello there" -- not updated profile - bob `send` "@cath hi" bob <### [ "member #team cath does not have direct connection, creating", @@ -3903,7 +3902,6 @@ testMemberContactProfileUpdate = bob #> "#team hello too" alice <# "#team rob> hello too" cath <# "#team rob> hello too" -- updated profile - cath #> "#team hello there" alice <# "#team kate> hello there" bob <# "#team kate> hello there" -- updated profile @@ -3911,7 +3909,7 @@ testMemberContactProfileUpdate = testGroupMsgForward :: HasCallStack => FilePath -> IO () testGroupMsgForward = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" @@ -3941,7 +3939,6 @@ setupGroupForwarding3 gName alice bob cath = do createGroup3 gName alice bob cath threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected - void $ withCCTransaction bob $ \db -> DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" void $ withCCTransaction cath $ \db -> @@ -3956,7 +3953,6 @@ testGroupMsgForwardDeduplicate = createGroup3 "team" alice bob cath threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected - void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" @@ -3990,7 +3986,7 @@ testGroupMsgForwardDeduplicate = testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" @@ -4001,7 +3997,6 @@ testGroupMsgForwardEdit = bob <# "#team [edited] hello there" alice <# "#team bob> [edited] hello there" cath <# "#team bob> [edited] hello there" -- TODO show as forwarded - alice ##> "/tail #team 1" alice <# "#team bob> hello there" @@ -4014,7 +4009,7 @@ testGroupMsgForwardEdit = testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" @@ -4031,7 +4026,7 @@ testGroupMsgForwardReaction = testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () testGroupMsgForwardDeletion = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath bob #> "#team hi there" @@ -4073,7 +4068,7 @@ testGroupMsgForwardFile = testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () testGroupMsgForwardChangeRole = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + \alice bob cath -> do setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" @@ -4084,7 +4079,7 @@ testGroupMsgForwardChangeRole = testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () testGroupMsgForwardNewMember = testChat4 aliceProfile bobProfile cathProfile danProfile $ - \alice bob cath dan -> do + \alice bob cath dan -> do setupGroupForwarding3 "team" alice bob cath connectUsers cath dan diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0a45a74ade..b9a908005d 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -7,16 +7,16 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import Control.Monad import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T +import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..)) +import Simplex.Messaging.Encoding.String (StrEncoding (..)) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec -import Simplex.Chat.Store.Shared (createContact) -import Control.Monad -import Simplex.Messaging.Encoding.String (StrEncoding(..)) chatProfileTests :: SpecWith FilePath chatProfileTests = do @@ -633,7 +633,7 @@ testPlanAddressOwn tmp = alice <## "alice_1 (Alice) wants to connect to you!" alice <## "to accept: /ac alice_1" alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" - alice @@@ [("<@alice_1", ""), (":2","")] + alice @@@ [("<@alice_1", ""), (":2", "")] alice ##> "/ac alice_1" alice <## "alice_1 (Alice): accepting contact request..." alice diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 40fe0e6dab..3f89e9b177 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -310,7 +310,7 @@ getInAnyOrder f cc ls = do Predicate p -> p l filterFirst :: (a -> Bool) -> [a] -> [a] filterFirst _ [] = [] - filterFirst p (x:xs) + filterFirst p (x : xs) | p x = xs | otherwise = x : filterFirst p xs @@ -593,7 +593,7 @@ vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxV linkAnotherSchema :: String -> String linkAnotherSchema link | "https://simplex.chat/" `isPrefixOf` link = - T.unpack $ T.replace "https://simplex.chat/" "simplex:/" $ T.pack link + T.unpack $ T.replace "https://simplex.chat/" "simplex:/" $ T.pack link | "simplex:/" `isPrefixOf` link = - T.unpack $ T.replace "simplex:/" "https://simplex.chat/" $ T.pack link + T.unpack $ T.replace "simplex:/" "https://simplex.chat/" $ T.pack link | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index ea6413834e..13bc2942fc 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -38,6 +38,7 @@ remoteTests = describe "Remote" $ do it "connects with stored pairing" remoteHandshakeStoredTest it "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest + it "connects with stored server bindings" storedBindingsTest it "sends messages" remoteMessageTest describe "remote files" $ do it "store/get/send/receive files" remoteStoreFileTest @@ -117,7 +118,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil mobileBob ##> "/set device name MobileBob" mobileBob <## "ok" desktop ##> "/start remote host 1" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) @@ -138,6 +139,37 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil desktop <## "remote host 1 connected" stopMobile mobile desktop +storedBindingsTest :: HasCallStack => FilePath -> IO () +storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile desktop -> do + desktop ##> "/set device name My desktop" + desktop <## "ok" + mobile ##> "/set device name Mobile" + mobile <## "ok" + + desktop ##> "/start remote host new addr=127.0.0.1 iface=lo port=52230" + desktop <##. "new remote host started on 127.0.0.1:52230" -- TODO: show ip? + desktop <## "Remote session invitation:" + inv <- getTermLine desktop + + mobile ##> ("/connect remote ctrl " <> inv) + mobile <## ("connecting new remote controller: My desktop, v" <> versionNumber) + 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" + + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <##. "1. Mobile (connected) [" + stopDesktop mobile desktop + desktop ##> "/list remote hosts" + desktop <## "Remote hosts:" + desktop <##. "1. Mobile [" + +-- TODO: more parser tests + remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do startRemote mobile desktop @@ -475,7 +507,7 @@ startRemote mobile desktop = do mobile ##> "/set device name Mobile" mobile <## "ok" desktop ##> "/start remote host new" - desktop <##. "new remote host started on port " + desktop <##. "new remote host started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -490,7 +522,7 @@ startRemote mobile desktop = do startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored mobile desktop = do desktop ##> "/start remote host 1" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -504,7 +536,7 @@ startRemoteStored mobile desktop = do startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover mobile desktop = do desktop ##> "/start remote host 1 multicast=on" - desktop <##. "remote host 1 started on port " + desktop <##. "remote host 1 started on " desktop <## "Remote session invitation:" _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" diff --git a/tests/Test.hs b/tests/Test.hs index 568f9688d0..ee5804aa9a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -13,8 +13,8 @@ import RemoteTests import SchemaDump import Test.Hspec import UnliftIO.Temporary (withTempDirectory) -import ViewTests import ValidNames +import ViewTests import WebRTCTests main :: IO ()