Compare commits

...

20 Commits

Author SHA1 Message Date
IC Rainbow 99711cbc08 rfc: paginated chat previews 2023-12-01 23:00:41 +02:00
Alexander Bondarenko 9a1c7f41f7 core: expand ranges to fit ghc 8.10 & 9.6 (#3496)
* expand ranges to fit ghc 8.10 & 9.6

* update nix

* use hashes from mq master

* fix more deps

* use network-transport from hackage
2023-12-01 16:52:47 +00:00
Stanislav Dmitrenko 40e69ae713 desktop: enable database operations (#3495)
* desktop: enable database operations

* disconnect hosts button

* not relaying on dev tools

* different logic

* different logic 2

* toggle placement
2023-12-01 15:04:00 +00:00
spaced4ndy b74e33b958 docs: inactive group members rfc (#3419) 2023-12-01 19:02:50 +04:00
Stanislav Dmitrenko 540c8883a0 android: do not show alert too early in obboarding (#3493) 2023-11-30 19:39:16 +00:00
Stanislav Dmitrenko 0e18b13bea desktop: adapting onboarding process to linking devices (#3490)
* desktop: adapting onboarding process to linking devices

* show progress on long operations

* changes

* clearing chat cache logic

* lines

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-30 19:38:21 +00:00
spaced4ndy a4b44254bc core: update simplexmq (ghc 8.10.7 compatibility) (#3492) 2023-11-30 21:09:07 +04:00
spaced4ndy 5819e42305 core: remove CRNewContactConnection response; mobile, desktop: create pending connections based on api responses (CRNewContactConnection was being used as "event" in UI) (#3489) 2023-11-30 20:31:32 +04:00
Jesse Horne 9580b4110d desktop: remember window position and size (#3465)
* initial work on storing desktop window position and size

* removed useless imports

* updated to use app preferences

* vars to vals

* defensive programming

* fixed default

* removed default json

* do nothing if encoding to json while storing fails

* names, clean up

* move comment

* changes

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2023-11-30 12:43:01 +00:00
Stanislav Dmitrenko 05a64c99a2 ios: moving webrtc commands processing to another mechanism (#3480)
* ios: moving webrtc commands processing to another mechanism

* async

* decide

* handle errors

* error alert

* await

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-28 17:36:05 +00:00
Alexander Bondarenko 6a21d5c7f1 add remote host bindings (#3471)
* add remote host bindings

* group iface/address together

* rename migration

* add implementation

* update view and api

* bump upstream

* add schema

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-28 16:32:33 +00:00
Stanislav Dmitrenko 950bbe19da ios: fix calls connecting state (#3475)
* ios: fix calls connecting state

* optimization

* changes

* removed relay protocol

* simplify

* use actor

* fix loop, better onChange, some questions

* remove extra iteration

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-27 22:20:51 +00:00
Evgeny Poberezkin 05278e5a06 core: allow remote host commands without user (#3478) 2023-11-27 18:34:15 +00:00
spaced4ndy 7a54d74517 Revert "ios: update libraries (#3474)"
This reverts commit bfcb2ac230.
2023-11-27 19:16:53 +04:00
spaced4ndy bfcb2ac230 ios: update libraries (#3474) 2023-11-27 19:02:44 +04:00
spaced4ndy 3073c4a1d5 core: fix chat previews showing not the latest message, fix message ordering in direct chats; mobile: update group previews only on timestamp increase (#3473) 2023-11-27 17:14:12 +04:00
Evgeny Poberezkin d4ac1c0cf2 core, ui: add remote host/controller stop reasons to events (#3472) 2023-11-26 23:23:37 +00:00
Evgeny Poberezkin d29f1bb0cf core: use fourmolu styles (#3470) 2023-11-26 18:16:37 +00:00
Jesse Horne 75c2de8a12 desktop: closing console window no longer closes entire application (#3466)
* closing the console now doesn't close all windows

* simplify

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-26 13:40:51 +00:00
Alexander Bondarenko f20ac33e67 cli: remove clashing short option for device-name (#3468) 2023-11-26 13:16:32 +00:00
100 changed files with 2323 additions and 1594 deletions
+15 -2
View File
@@ -83,7 +83,7 @@ final class ChatModel: ObservableObject {
// current WebRTC call // current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:] @Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call? @Published var activeCall: Call?
@Published var callCommand: WCallCommand? let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
@Published var showCallView = false @Published var showCallView = false
// remote desktop // remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession? @Published var remoteCtrlSession: RemoteCtrlSession?
@@ -267,7 +267,20 @@ final class ChatModel: ObservableObject {
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews // update previews
if let i = getChatIndex(cInfo.id) { 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 { if case .rcvNew = cItem.meta.itemStatus {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!) increaseUnreadCounter(user: currentUser!)
+24 -30
View File
@@ -605,27 +605,29 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
throw r throw r
} }
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert { if let alert = alert {
AlertManager.shared.showAlert(alert) AlertManager.shared.showAlert(alert)
return nil return nil
} else { } 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 { guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user") logger.error("apiConnect: no current user")
return (nil, nil) return (nil, nil)
} }
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
let m = ChatModel.shared
switch r { switch r {
case .sentConfirmation: return (.invitation, nil) case let .sentConfirmation(_, connection):
case .sentInvitation: return (.contact, nil) return ((.invitation, connection), nil)
case let .sentInvitation(_, connection):
return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact): case let .contactAlreadyExists(_, contact):
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id } await MainActor.run { m.chatId = c.id }
} }
@@ -1362,18 +1364,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared let m = ChatModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { 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): case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
await MainActor.run { await MainActor.run {
@@ -1666,36 +1656,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
activateCall(invitation) activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _): case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in await withCall(contact) { call in
call.callState = .offerReceived await MainActor.run {
call.peerMedia = callType.media call.callState = .offerReceived
call.sharedKey = sharedKey call.peerMedia = callType.media
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers() let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)") logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))") logger.debug(".callOffer iceServers \(String(describing: iceServers))")
m.callCommand = .offer( await m.callCommand.processCommand(.offer(
offer: offer.rtcSession, offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates, iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey, media: callType.media, aesKey: sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
) ))
} }
case let .callAnswer(_, contact, answer): case let .callAnswer(_, contact, answer):
await withCall(contact) { call in await withCall(contact) { call in
call.callState = .answerReceived await MainActor.run {
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) call.callState = .answerReceived
}
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
} }
case let .callExtraInfo(_, contact, extraInfo): case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in await withCall(contact) { _ in
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
} }
case let .callEnded(_, contact): case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation) CallController.shared.reportCallRemoteEnded(invitation: invitation)
} }
await withCall(contact) { call in await withCall(contact) { call in
m.callCommand = .end await m.callCommand.processCommand(.end)
CallController.shared.reportCallRemoteEnded(call: call) CallController.shared.reportCallRemoteEnded(call: call)
} }
case .chatSuspended: case .chatSuspended:
@@ -1753,9 +1747,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
logger.debug("unsupported event: \(res.responseType)") 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 { if let call = m.activeCall, call.contact.apiId == contact.apiId {
await MainActor.run { perform(call) } await perform(call)
} else { } else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
} }
@@ -49,10 +49,10 @@ struct ActiveCallView: View {
} }
.onDisappear { .onDisappear {
logger.debug("ActiveCallView: disappear") logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
} }
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
.background(.black) .background(.black)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
@@ -60,19 +60,8 @@ struct ActiveCallView: View {
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { if client == nil && canConnectCall {
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) 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 { Task {
await client.sendCallCommand(command: cmd) await m.callCommand.setClient(client)
} }
} }
} }
@@ -168,8 +157,10 @@ struct ActiveCallView: View {
} }
case let .error(message): case let .error(message):
logger.debug("ActiveCallView: command error: \(message)") logger.debug("ActiveCallView: command error: \(message)")
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
case let .invalid(type): case let .invalid(type):
logger.debug("ActiveCallView: invalid response: \(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 { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")") Text("(") + Text(connInfo.text) + Text(")")
} }
} }
+12 -8
View File
@@ -22,7 +22,7 @@ class CallManager {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callkitUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
m.callCommand = .capabilities(media: call.localMedia) Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true return true
} }
return false return false
@@ -57,19 +57,21 @@ class CallManager {
m.activeCall = call m.activeCall = call
m.showCallView = true m.showCallView = true
m.callCommand = .start( Task {
await m.callCommand.processCommand(.start(
media: invitation.callType.media, media: invitation.callType.media,
aesKey: invitation.sharedKey, aesKey: invitation.sharedKey,
iceServers: iceServers, iceServers: iceServers,
relay: useRelay relay: useRelay
) ))
}
} }
} }
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
m.callCommand = .media(media: media, enable: enable) Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
return true return true
} }
return false return false
@@ -94,11 +96,13 @@ class CallManager {
completed() completed()
} else { } else {
logger.debug("CallManager.endCall: ending call...") logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end
m.activeCall = nil
m.showCallView = false
completed()
Task { Task {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil
m.showCallView = false
completed()
}
do { do {
try await apiEndCall(call.contact) try await apiEndCall(call.contact)
} catch { } catch {
+44 -14
View File
@@ -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 { struct ConnectionState: Codable, Equatable {
var connectionState: String var connectionState: String
var iceConnectionState: String var iceConnectionState: String
@@ -358,26 +402,12 @@ struct ConnectionInfo: Codable, Equatable {
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)" 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 // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable { struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType? var candidateType: RTCIceCandidateType?
var `protocol`: String? var `protocol`: String?
var relayProtocol: String?
var sdpMid: String? var sdpMid: String?
var sdpMLineIndex: Int? var sdpMLineIndex: Int?
var candidate: String var candidate: String
+113 -73
View File
@@ -21,7 +21,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
struct Call { struct Call {
var connection: RTCPeerConnection var connection: RTCPeerConnection
var iceCandidates: [RTCIceCandidate] var iceCandidates: IceCandidates
var localMedia: CallMediaType var localMedia: CallMediaType
var localCamera: RTCVideoCapturer? var localCamera: RTCVideoCapturer?
var localVideoSource: RTCVideoSource? var localVideoSource: RTCVideoSource?
@@ -33,10 +33,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameDecryptor: RTCFrameDecryptor? 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 rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "audio") private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
private var activeCall: Binding<Call?> var activeCall: Binding<Call?>
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
@available(*, unavailable) @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"), 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) let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self connection.delegate = self
createAudioSender(connection) createAudioSender(connection)
@@ -87,7 +101,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
return Call( return Call(
connection: connection, connection: connection,
iceCandidates: remoteIceCandidates, iceCandidates: IceCandidates(),
localMedia: mediaType, localMedia: mediaType,
localCamera: localCamera, localCamera: localCamera,
localVideoSource: localVideoSource, localVideoSource: localVideoSource,
@@ -144,26 +158,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall.wrappedValue != nil { endCall() } if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption 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 activeCall.wrappedValue = call
call.connection.offer { answer in let (offer, error) = await call.connection.offer()
Task { if let offer = offer {
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 }) resp = .offer(
if gotCandidates { offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
await self.sendCallResponse(.init( iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
corrId: nil, capabilities: CallCapabilities(encryption: encryption)
resp: .offer( )
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), self.waitForMoreIceCandidates()
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])), } else {
capabilities: CallCapabilities(encryption: encryption) resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
),
command: command)
)
} else {
self.endCall()
}
}
} }
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall.wrappedValue != nil { if activeCall.wrappedValue != nil {
@@ -172,26 +178,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "accept: encryption is not supported") resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)), } else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { 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 activeCall.wrappedValue = call
let pc = call.connection let pc = call.connection
if let type = offer.type, let sdp = offer.sdp { if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil { 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) self.addIceCandidates(pc, remoteIceCandidates)
// Task { resp = .answer(
// try? await Task.sleep(nanoseconds: 32_000 * 1000000) answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
Task { iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
await self.sendCallResponse(.init( )
corrId: nil, self.waitForMoreIceCandidates()
resp: .answer( } else {
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))), resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
),
command: command)
)
}
// }
} }
} else { } else {
resp = .error(message: "accept: remote description is not set") resp = .error(message: "accept: remote description is not set")
@@ -234,6 +235,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .ok resp = .ok
} }
case .end: case .end:
// TODO possibly, endCall should be called before returning .ok
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command)) await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
endCall() 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) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
@@ -387,12 +416,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
audioSessionToDefaults() audioSessionToDefaults()
} }
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool { func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
let startedAt = DispatchTime.now() var t: UInt64 = 0
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds { repeat {
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break } _ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
} t += stepMs
return success() await action()
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@@ -405,25 +435,33 @@ extension WebRTC.RTCPeerConnection {
optionalConstraints: nil) optionalConstraints: nil)
} }
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { func offer() async -> (RTCSessionDescription?, Error?) {
offer(for: mediaConstraints()) { (sdp, error) in await withCheckedContinuation { cont in
guard let sdp = sdp else { offer(for: mediaConstraints()) { (sdp, error) in
return self.processSDP(cont, sdp, error)
} }
self.setLocalDescription(sdp, completionHandler: { (error) in
completion(sdp)
})
} }
} }
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { func answer() async -> (RTCSessionDescription?, Error?) {
answer(for: mediaConstraints()) { (sdp, error) in await withCheckedContinuation { cont in
guard let sdp = sdp else { answer(for: mediaConstraints()) { (sdp, error) in
return 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 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 default: enableSpeaker = false
} }
setSpeakerEnabledAndConfigureSession(enableSpeaker) setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall() case .disconnected, .failed: endCall()
default: do {} default: do {}
} }
@@ -491,7 +530,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) { func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)") // 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]) { func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
@@ -506,10 +547,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
lastReceivedMs lastDataReceivedMs: Int32, lastReceivedMs lastDataReceivedMs: Int32,
changeReason reason: String) { changeReason reason: String) {
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)") // 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 connection.statistics { (stats: RTCStatisticsReport) in
stats.statistics.values.forEach { stat in stats.statistics.values.forEach { stat in
// logger.debug("Stat \(stat.debugDescription)") // logger.debug("Stat \(stat.debugDescription)")
@@ -517,24 +557,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
let localId = stat.values["localCandidateId"] as? String, let localId = stat.values["localCandidateId"] as? String,
let remoteId = stat.values["remoteCandidateId"] as? String, let remoteId = stat.values["remoteCandidateId"] as? String,
let localStats = stats.statistics[localId], let localStats = stats.statistics[localId],
let remoteStats = stats.statistics[remoteId], 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 ?? "--"))")
{ {
Task { Task {
await self.sendCallResponse(.init( await self.sendCallResponse(.init(
corrId: nil, corrId: nil,
resp: .connected(connectionInfo: ConnectionInfo( resp: .connected(connectionInfo: ConnectionInfo(
localCandidate: local.toCandidate( localCandidate: RTCIceCandidate(
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String), candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
localStats.values["protocol"] as? String, protocol: localStats.values["protocol"] as? String,
localStats.values["relayProtocol"] as? String sdpMid: nil,
sdpMLineIndex: nil,
candidate: ""
), ),
remoteCandidate: remote.toCandidate( remoteCandidate: RTCIceCandidate(
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String), candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
remoteStats.values["protocol"] as? String, protocol: remoteStats.values["protocol"] as? String,
remoteStats.values["relayProtocol"] as? String sdpMid: nil,
))), sdpMLineIndex: nil,
candidate: ""))),
command: nil) command: nil)
) )
} }
@@ -634,11 +675,10 @@ extension RTCIceCandidate {
} }
extension WebRTC.RTCIceCandidate { extension WebRTC.RTCIceCandidate {
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate { func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
RTCIceCandidate( RTCIceCandidate(
candidateType: candidateType, candidateType: candidateType,
protocol: `protocol`, protocol: `protocol`,
relayProtocol: relayProtocol,
sdpMid: sdpMid, sdpMid: sdpMid,
sdpMLineIndex: Int(sdpMLineIndex), sdpMLineIndex: Int(sdpMLineIndex),
candidate: sdp candidate: sdp
@@ -73,6 +73,7 @@ struct CreateLinkView: View {
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run { await MainActor.run {
m.updateContactConnection(pcc)
connReqInvitation = connReq connReqInvitation = connReq
contactConnection = pcc contactConnection = pcc
m.connReqInv = connReq m.connReqInv = connReq
@@ -52,6 +52,9 @@ struct NewChatButton: View {
func addContactAction() { func addContactAction() {
Task { Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
actionSheet = .createLink(link: connReq, connection: 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) { private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
Task { 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 let crt: ConnReqType
if let plan = connectionPlan { if let plan = connectionPlan {
crt = planToConnReqType(plan) crt = planToConnReqType(plan)
+13 -9
View File
@@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error {
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef) case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef) case sentInvitation(user: UserRef, connection: PendingContactConnection)
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
case contactAlreadyExists(user: UserRef, contact: Contact) case contactAlreadyExists(user: UserRef, contact: Contact)
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
@@ -605,7 +605,6 @@ public enum ChatResponse: Decodable, Error {
case ntfTokenStatus(status: NtfTknStatus) case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case newContactConnection(user: UserRef, connection: PendingContactConnection)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
// remote desktop responses/events // remote desktop responses/events
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
@@ -613,7 +612,7 @@ public enum ChatResponse: Decodable, Error {
case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String) case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo) case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
case remoteCtrlStopped case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
// misc // misc
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration]) case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
case cmdOk(user: UserRef?) case cmdOk(user: UserRef?)
@@ -752,7 +751,6 @@ public enum ChatResponse: Decodable, Error {
case .ntfTokenStatus: return "ntfTokenStatus" case .ntfTokenStatus: return "ntfTokenStatus"
case .ntfToken: return "ntfToken" case .ntfToken: return "ntfToken"
case .ntfMessages: return "ntfMessages" case .ntfMessages: return "ntfMessages"
case .newContactConnection: return "newContactConnection"
case .contactConnectionDeleted: return "contactConnectionDeleted" case .contactConnectionDeleted: return "contactConnectionDeleted"
case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlList: return "remoteCtrlList"
case .remoteCtrlFound: return "remoteCtrlFound" 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 .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 .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 .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 .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
case .sentConfirmation: return noDetails case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case .sentInvitation: return noDetails case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
case let .contactAlreadyExists(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)) 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 .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" 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 .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 .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) 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)" 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) 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 struct CtrlAppInfo: Decodable {
public var appVersionRange: AppVersionRange public var appVersionRange: AppVersionRange
public var deviceName: String public var deviceName: String
@@ -75,7 +75,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true isAppOnForeground = true
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
SimplexService.showBackgroundServiceNoticeIfNeeded() SimplexService.showBackgroundServiceNoticeIfNeeded()
} }
/** /**
@@ -370,7 +370,6 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
InfoText(call.callState.text) InfoText(call.callState.text)
val connInfo = call.connectionInfo val connInfo = call.connectionInfo
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText) InfoText(call.encryptionStatus + connInfoText)
} }
@@ -585,8 +584,8 @@ fun PreviewActiveCallOverlayVideo() {
localMedia = CallMediaType.Video, localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video, peerMedia = CallMediaType.Video,
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null), RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null) RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
) )
), ),
speakerCanBeEnabled = true, speakerCanBeEnabled = true,
@@ -611,8 +610,8 @@ fun PreviewActiveCallOverlayAudio() {
localMedia = CallMediaType.Audio, localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio, peerMedia = CallMediaType.Audio,
connectionInfo = ConnectionInfo( connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null), RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null) RTCIceCandidate(RTCIceCandidateType.Host, "udp")
) )
), ),
speakerCanBeEnabled = true, speakerCanBeEnabled = true,
@@ -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<OnboardingStage>, 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)
}
}
@@ -37,8 +37,7 @@ import kotlinx.coroutines.flow.*
data class SettingsViewState( data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>, val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState, val scaffoldState: ScaffoldState
val switchingUsersAndHosts: MutableState<Boolean>
) )
@Composable @Composable
@@ -102,11 +101,8 @@ fun MainScreen() {
} }
Box { Box {
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state }
LaunchedEffect(Unit) { val localUserCreated = chatModel.localUserCreated.value
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) } var showInitializationView by remember { mutableStateOf(false) }
when { when {
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
@@ -115,14 +111,18 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
} }
} }
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> { onboarding == OnboardingStage.OnboardingComplete -> {
Box { Box {
showAdvertiseLAAlert = true 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 scaffoldState = rememberScaffoldState()
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
AndroidScreen(settingsState) AndroidScreen(settingsState)
} else { } else {
@@ -137,12 +137,14 @@ fun MainScreen() {
} }
} }
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
onboarding == OnboardingStage.LinkAMobile -> LinkAMobile()
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView() ModalManager.fullscreen.showInView()
SwitchingUsersView()
} }
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } } val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
@@ -262,7 +264,7 @@ fun CenterPartOfScreen() {
.background(MaterialTheme.colors.background), .background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center 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 { } else {
ModalManager.center.showInView() ModalManager.center.showInView()
@@ -286,6 +288,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
} }
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) { Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
ModalManager.start.showInView() ModalManager.start.showInView()
SwitchingUsersView()
} }
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) { Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
@@ -298,7 +301,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
EndPartOfScreen() EndPartOfScreen()
} }
} }
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) { if (scaffoldState.drawerState.isOpen) {
Box( Box(
@@ -312,7 +315,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
) )
} }
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) 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() } scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
@@ -335,3 +338,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
)
}
@@ -2,7 +2,7 @@ package chat.simplex.common.model
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -43,7 +43,7 @@ object ChatModel {
val setDeliveryReceipts = mutableStateOf(false) val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null) val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>() val users = mutableStateListOf<UserInfo>()
val userCreated = mutableStateOf<Boolean?>(null) val localUserCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null) val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false) val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false) val chatDbEncrypted = mutableStateOf<Boolean?>(false)
@@ -51,6 +51,7 @@ object ChatModel {
val chats = mutableStateListOf<Chat>() val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id // map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>() val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
@@ -108,6 +109,9 @@ object ChatModel {
var updatingChatsMutex: Mutex = Mutex() 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 // remote controller
val remoteHosts = mutableStateListOf<RemoteHostInfo>() val remoteHosts = mutableStateListOf<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null) val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
@@ -222,8 +226,23 @@ object ChatModel {
val chat: Chat val chat: Chat
if (i >= 0) { if (i >= 0) {
chat = chats[i] 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( chats[i] = chat.copy(
chatItems = arrayListOf(cItem), chatItems = arrayListOf(newPreviewItem),
chatStats = chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) { if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
@@ -605,6 +624,7 @@ object ChatModel {
terminalItems.add(item) 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 fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
} }
@@ -2945,6 +2965,14 @@ sealed class RemoteCtrlSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): 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 { sealed class UIRemoteCtrlSessionState {
object Starting: UIRemoteCtrlSessionState() object Starting: UIRemoteCtrlSessionState()
object Searching: UIRemoteCtrlSessionState() object Searching: UIRemoteCtrlSessionState()
@@ -173,6 +173,8 @@ class AppPreferences {
val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true) val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true)
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, 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) = private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference( SharedPreference(
get = fun() = settings.getInt(prefName, default), 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 = "ConnectRemoteViaMulticast"
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" 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_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
} }
} }
@@ -359,7 +362,7 @@ object ChatController {
chatModel.users.addAll(users) chatModel.users.addAll(users)
if (justStarted) { if (justStarted) {
chatModel.currentUser.value = user chatModel.currentUser.value = user
chatModel.userCreated.value = true chatModel.localUserCreated.value = true
getUserChatData(null) getUserChatData(null)
appPrefs.chatLastStart.set(Clock.System.now()) appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true 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?) { suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
try { try {
changeActiveUser_(rhId, toUserId, viewPwd) changeActiveUser_(rhId, toUserId, viewPwd)
@@ -402,8 +430,9 @@ object ChatController {
} }
suspend fun getUserChatData(rhId: Long?) { suspend fun getUserChatData(rhId: Long?) {
chatModel.userAddress.value = apiGetUserAddress(rhId) val hasUser = chatModel.currentUser.value != null
chatModel.chatItemTTL.value = getChatItemTTL(rhId) chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
updatingChatsMutex.withLock { updatingChatsMutex.withLock {
val chats = apiGetChats(rhId) val chats = apiGetChats(rhId)
chatModel.updateChats(chats) chatModel.updateChats(chats)
@@ -472,7 +501,9 @@ object ChatController {
val r = sendCmd(rh, CC.ShowActiveUser()) val r = sendCmd(rh, CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false if (rh == null) {
chatModel.localUserCreated.value = false
}
return null return null
} }
@@ -891,20 +922,21 @@ object ChatController {
return null 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 { val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user") Log.e(TAG, "apiConnect: no current user")
return false return null
} }
val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq)) val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq))
when { 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 -> { r is CR.ContactAlreadyExists -> {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.contact_already_exists), generalGetString(MR.strings.contact_already_exists),
String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) 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 is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> { && r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
@@ -912,7 +944,7 @@ object ChatController {
generalGetString(MR.strings.invalid_connection_link), generalGetString(MR.strings.invalid_connection_link),
generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one) 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 is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP && r.chatError.agentError is AgentErrorType.SMP
@@ -921,13 +953,13 @@ object ChatController {
generalGetString(MR.strings.connection_error_auth), generalGetString(MR.strings.connection_error_auth),
generalGetString(MR.strings.connection_error_auth_desc) generalGetString(MR.strings.connection_error_auth_desc)
) )
return false return null
} }
else -> { else -> {
if (!(networkErrorAlert(r))) { if (!(networkErrorAlert(r))) {
apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r) apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r)
} }
return false return null
} }
} }
} }
@@ -1526,16 +1558,6 @@ object ChatController {
fun active(user: UserLike): Boolean = activeUser(rhId, user) fun active(user: UserLike): Boolean = activeUser(rhId, user)
chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
when (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 -> { is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) { if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact) chatModel.updateContact(rhId, r.contact)
@@ -1996,7 +2018,7 @@ object ChatController {
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) 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? // TODO lock the switch so that two switches can't run concurrently?
chatModel.chatId.value = null chatModel.chatId.value = null
ModalManager.center.closeModals() ModalManager.center.closeModals()
@@ -2009,7 +2031,10 @@ object ChatController {
chatModel.users.clear() chatModel.users.clear()
chatModel.users.addAll(users) chatModel.users.addAll(users)
chatModel.currentUser.value = user chatModel.currentUser.value = user
chatModel.userCreated.value = true if (user == null) {
chatModel.chatItems.clear()
chatModel.chats.clear()
}
val statuses = apiGetNetworkStatuses(rhId) val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) { if (statuses != null) {
chatModel.networkStatuses.clear() chatModel.networkStatuses.clear()
@@ -2019,6 +2044,23 @@ object ChatController {
getUserChatData(rhId) 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 { fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0) return XFTPFileConfig(minFileSize = 0)
} }
@@ -3581,6 +3623,13 @@ sealed class RemoteHostSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): 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 { val json = Json {
prettyPrint = true prettyPrint = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -3700,8 +3749,8 @@ sealed class CR {
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): 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("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): 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("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("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@@ -3795,7 +3844,6 @@ sealed class CR {
@Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): 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("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("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() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
// remote events (desktop) // remote events (desktop)
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR() @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
@@ -3804,7 +3852,7 @@ sealed class CR {
@Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: 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("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(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() @Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR()
// remote events (mobile) // remote events (mobile)
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR() @Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
@@ -3812,7 +3860,7 @@ sealed class CR {
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): 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("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR()
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): 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<UpMigration>, val agentMigrations: List<UpMigration>): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo, val chatMigrations: List<UpMigration>, val agentMigrations: List<UpMigration>): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR() @Serializable @SerialName("cmdOk") class CmdOk(val user: UserRef?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
@@ -3944,7 +3992,6 @@ sealed class CR {
is CallAnswer -> "callAnswer" is CallAnswer -> "callAnswer"
is CallExtraInfo -> "callExtraInfo" is CallExtraInfo -> "callExtraInfo"
is CallEnded -> "callEnded" is CallEnded -> "callEnded"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted" is ContactConnectionDeleted -> "contactConnectionDeleted"
is RemoteHostList -> "remoteHostList" is RemoteHostList -> "remoteHostList"
is CurrentRemoteHost -> "currentRemoteHost" is CurrentRemoteHost -> "currentRemoteHost"
@@ -3999,11 +4046,11 @@ sealed class CR {
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") 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 GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") 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 ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, noDetails()) is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, json.encodeToString(connection))
is SentInvitationToContact -> withUser(user, json.encodeToString(contact)) is SentInvitationToContact -> withUser(user, json.encodeToString(contact))
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
@@ -4091,7 +4138,6 @@ sealed class CR {
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}") is CallEnded -> withUser(user, "contact: ${contact.id}")
is NewContactConnection -> withUser(user, json.encodeToString(connection))
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
// remote events (mobile) // remote events (mobile)
is RemoteHostList -> json.encodeToString(remoteHosts) is RemoteHostList -> json.encodeToString(remoteHosts)
@@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null) val user = chatController.apiGetActiveUser(null)
if (user == null) { if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null chatModel.currentUser.value = null
chatModel.users.clear() 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 { } else {
val savedOnboardingStage = appPreferences.onboardingStage.get() val savedOnboardingStage = appPreferences.onboardingStage.get()
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
@@ -59,7 +59,9 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // 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 val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true chatModel.clearOverlays.value = true
@@ -72,7 +74,9 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel) awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications? // 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.chatId.value = null
chatModel.clearOverlays.value = true chatModel.clearOverlays.value = true
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.* import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.*
import chat.simplex.common.model.Profile import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
disabled = !canCreateProfile(displayName.value), disabled = !canCreateProfile(displayName.value),
textColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary,
iconColor = 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.your_profile_is_stored_on_your_device))
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) 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) { fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi { withApi {
val rhId = chatModel.remoteHostId() val rhId = chatModel.remoteHostId()
@@ -127,18 +127,10 @@ sealed class WCallResponse {
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" "${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 // 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 // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null) @Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
@@ -68,14 +68,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") } var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope() 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() } } }, Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid, drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = { floatingActionButton = {
if (searchInList.isEmpty()) { if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
if (!stopped) { if (!stopped) {
@@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
) { ) {
if (chatModel.chats.isNotEmpty()) { if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList) ChatList(chatModel, search = searchInList)
} else if (!switchingUsersAndHosts.value) { } else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet) OnboardingButtons(showNewChatSheet)
@@ -121,19 +121,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
} }
if (switchingUsersAndHosts.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
} }
@Composable @Composable
@@ -209,7 +201,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
navigationButton = { navigationButton = {
if (showSearch) { if (showSearch) {
NavigationButtonBack(hideSearchOnBack) 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() } } NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else { } else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } 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 @Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") } var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold( Scaffold(
Modifier.padding(end = endPadding), Modifier.padding(end = endPadding),
@@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
} }
} }
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
}) })
@@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.remote.* import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -38,7 +40,6 @@ import kotlin.math.roundToInt
fun UserPicker( fun UserPicker(
chatModel: ChatModel, chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>, userPickerState: MutableStateFlow<AnimatedViewState>,
switchingUsersAndHosts: MutableState<Boolean>,
showSettings: Boolean = true, showSettings: Boolean = true,
showCancel: Boolean = false, showCancel: Boolean = false,
cancelClicked: () -> Unit = {}, cancelClicked: () -> Unit = {},
@@ -123,14 +124,10 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) { if (!u.user.activeUser) {
scope.launch { scope.launch {
val job = launch { controller.showProgressIfNeeded {
delay(500) ModalManager.closeAllModalsEverywhere()
switchingUsersAndHosts.value = true 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 val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) { if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null) { if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) { LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice() switchToLocalDevice()
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} else { } else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) } val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost, RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = { actionButtonClick = {
@@ -176,7 +173,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(currentRemoteHost, true) stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) { }) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) switchToRemoteHost(currentRemoteHost, connecting)
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} }
@@ -184,7 +181,7 @@ fun UserPicker(
UsersView() UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(false) { LocalDevicePickerItem(false) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice() switchToLocalDevice()
@@ -199,7 +196,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(h, false) stopRemoteHostAndReloadHosts(h, false)
}) { }) {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, switchingUsersAndHosts, connecting) switchToRemoteHost(h, connecting)
} }
Divider(Modifier.requiredHeight(1.dp)) Divider(Modifier.requiredHeight(1.dp))
} }
@@ -220,6 +217,18 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.GONE userPickerState.value = AnimatedViewState.GONE
} }
Divider(Modifier.requiredHeight(1.dp)) 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) { if (showSettings) {
SettingsPickerItem(settingsClicked) 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 @Composable
private fun SettingsPickerItem(onClick: () -> Unit) { private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { 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<Boolean>, connecting: MutableState<Boolean>) { private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState<Boolean>) {
if (!h.activeHost()) { if (!h.activeHost()) {
withBGApi { withBGApi {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere() ModalManager.closeAllModalsEverywhere()
if (h.sessionState != null) { if (h.sessionState != null) {
chatModel.controller.switchUIRemoteHost(h.remoteHostId) chatModel.controller.switchUIRemoteHost(h.remoteHostId)
} else { } else {
connectMobileDevice(h, connecting) connectMobileDevice(h, connecting)
} }
job.cancel()
switchingUsersAndHosts.value = false
} }
} else { } else {
connectMobileDevice(h, connecting) connectMobileDevice(h, connecting)
@@ -264,7 +264,8 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
text, text,
generalGetString(MR.strings.enter_passphrase), generalGetString(MR.strings.enter_passphrase),
isValid = ::validKey, 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() } { onClick?.invoke() }
} else null } else null
), ),
@@ -4,6 +4,7 @@ import SectionBottomSpacer
import SectionDividerSpaced import SectionDividerSpaced
import SectionTextFooter import SectionTextFooter
import SectionItemView import SectionItemView
import SectionSpacer
import SectionView import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.* 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -59,7 +61,9 @@ fun DatabaseView(
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) { 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) } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
@@ -77,7 +81,6 @@ fun DatabaseView(
m.chatDbEncrypted.value, m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase, m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher, importArchiveLauncher,
chatArchiveName, chatArchiveName,
chatArchiveTime, chatArchiveTime,
@@ -100,7 +103,13 @@ fun DatabaseView(
setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) 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) { if (progressIndicator.value) {
Box( Box(
@@ -129,7 +138,6 @@ fun DatabaseLayout(
chatDbEncrypted: Boolean?, chatDbEncrypted: Boolean?,
passphraseSaved: Boolean, passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher, importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>, chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>, chatArchiveTime: MutableState<Instant?>,
@@ -144,36 +152,43 @@ fun DatabaseLayout(
deleteChatAlert: () -> Unit, deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit, deleteAppFilesAndMedia: () -> Unit,
onChatItemTTLSelected: (ChatItemTTL) -> Unit, onChatItemTTLSelected: (ChatItemTTL) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
disconnectAllHosts: () -> Unit,
) { ) {
val stopped = !runChat val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
Column( Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) { ) {
AppBarTitle(stringResource(MR.strings.your_chat_database)) AppBarTitle(stringResource(MR.strings.your_chat_database))
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { if (!chatModel.desktopNoUserNoRemote) {
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) 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(".")
}
} }
) SectionTextFooter(
remember(currentUser?.displayName) {
if (currentRemoteHost == null) { buildAnnotatedString {
append(generalGetString(MR.strings.messages_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
}
)
SectionDividerSpaced(maxTopPadding = true) 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)) { 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( SectionTextFooter(
if (stopped) { if (stopped) {
@@ -183,92 +198,96 @@ fun DatabaseLayout(
} }
) )
SectionDividerSpaced() 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() SectionBottomSpacer()
} }
} }
@@ -319,6 +338,7 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
fun RunChatSetting( fun RunChatSetting(
runChat: Boolean, runChat: Boolean,
stopped: Boolean, stopped: Boolean,
enabled: Boolean,
startChat: () -> Unit, startChat: () -> Unit,
stopChatAlert: () -> Unit stopChatAlert: () -> Unit
) { ) {
@@ -337,6 +357,7 @@ fun RunChatSetting(
stopChatAlert() stopChatAlert()
} }
}, },
enabled = enabled,
) )
} }
} }
@@ -501,13 +522,14 @@ private fun importArchiveAlert(
m: ChatModel, m: ChatModel,
importedArchiveURI: URI, importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>, appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean> progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) { ) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.import_database_question), title = generalGetString(MR.strings.import_database_question),
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(MR.strings.import_database_confirmation), confirmText = generalGetString(MR.strings.import_database_confirmation),
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) }, onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) },
destructive = true, destructive = true,
) )
} }
@@ -516,7 +538,8 @@ private fun importArchive(
m: ChatModel, m: ChatModel,
importedArchiveURI: URI, importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>, appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean> progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
) { ) {
progressIndicator.value = true progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI) val archivePath = saveArchiveFromURI(importedArchiveURI)
@@ -533,6 +556,10 @@ private fun importArchive(
operationEnded(m, progressIndicator) { operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database)) 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 { } else {
operationEnded(m, progressIndicator) { 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)) 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, chatDbEncrypted = false,
passphraseSaved = false, passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}), initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {}, importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@@ -697,6 +723,7 @@ fun PreviewDatabaseLayout() {
deleteAppFilesAndMedia = {}, deleteAppFilesAndMedia = {},
showSettingsModal = { {} }, showSettingsModal = { {} },
onChatItemTTLSelected = {}, onChatItemTTLSelected = {},
disconnectAllHosts = {},
) )
} }
} }
@@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.onRightClick
@@ -202,13 +203,14 @@ fun SectionTextFooter(text: String) {
} }
@Composable @Composable
fun SectionTextFooter(text: AnnotatedString) { fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) {
Text( Text(
text, text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
color = MaterialTheme.colors.secondary, color = MaterialTheme.colors.secondary,
lineHeight = 18.sp, lineHeight = 18.sp,
fontSize = 14.sp fontSize = 14.sp,
textAlign = textAlign
) )
} }
@@ -108,6 +108,7 @@ private fun createInvitation(
withApi { withApi {
val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get())
if (r != null) { if (r != null) {
m.updateContactConnection(rhId, r.second)
connReqInvitation.value = r.first connReqInvitation.value = r.first
contactConnection.value = r.second contactConnection.value = r.second
} else { } else {
@@ -283,10 +283,11 @@ suspend fun connectViaUri(
incognito: Boolean, incognito: Boolean,
connectionPlan: ConnectionPlan?, connectionPlan: ConnectionPlan?,
close: (() -> Unit)? 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 val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
if (r) { if (pcc != null) {
chatModel.updateContactConnection(rhId, pcc)
close?.invoke() close?.invoke()
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent), title = generalGetString(MR.strings.connection_request_sent),
@@ -299,7 +300,6 @@ suspend fun connectViaUri(
hostDevice = hostDevice(rhId), hostDevice = hostDevice(rhId),
) )
} }
return r
} }
fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType {
@@ -182,6 +182,10 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) {
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi
chatModel.currentUser.value = user chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) { 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) chatModel.controller.startChat(user)
} else { } else {
val users = chatModel.controller.listUsers(rhId) val users = chatModel.controller.listUsers(rhId)
@@ -0,0 +1,91 @@
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<String?>,
connecting: MutableState<Boolean>,
staleQrCode: MutableState<Boolean>,
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) {
if (chatModel.remoteHosts.isEmpty()) {
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) }
}
}
@@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding
enum class OnboardingStage { enum class OnboardingStage {
Step1_SimpleXInfo, Step1_SimpleXInfo,
Step2_CreateProfile, Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase, Step2_5_SetupDatabasePassphrase,
Step3_CreateSimpleXAddress, Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode, Step4_SetNotificationsMode,
@@ -43,7 +43,11 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val newKey = rememberSaveable { mutableStateOf("") } val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") } val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() { 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( SetupDatabasePassphraseLayout(
currentKey, currentKey,
@@ -159,10 +163,7 @@ private fun SetupDatabasePassphraseLayout(
} }
}, },
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = { keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
) )
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {
@@ -8,6 +8,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
@@ -99,26 +100,22 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
} }
@Composable @Composable
fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) { expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, 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)
}
}
@Composable @Composable
fun OnboardingActionButton( fun OnboardingActionButton(
labelId: StringResource, labelId: StringResource,
onboarding: OnboardingStage?, onboarding: OnboardingStage?,
border: Boolean, border: Boolean,
icon: Painter? = null,
iconColor: Color = MaterialTheme.colors.primary,
onclick: (() -> Unit)? onclick: (() -> Unit)?
) { ) {
val modifier = if (border) { val modifier = if (border) {
Modifier Modifier
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50)) .border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
.padding( .padding(
horizontal = DEFAULT_PADDING * 2, horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF,
vertical = 4.dp vertical = 4.dp
) )
} else { } else {
@@ -131,6 +128,9 @@ fun OnboardingActionButton(
ChatController.appPrefs.onboardingStage.set(onboarding) ChatController.appPrefs.onboardingStage.set(onboarding)
} }
}, modifier) { }, 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) Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
Icon( Icon(
painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary, painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary,
@@ -13,12 +13,14 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.* import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.input.* import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.* import chat.simplex.common.model.*
@@ -97,9 +99,11 @@ fun ConnectMobileLayout(
SectionDividerSpaced(maxBottomPadding = false) SectionDividerSpaced(maxBottomPadding = false)
} }
SectionView(stringResource(MR.strings.devices).uppercase()) { SectionView(stringResource(MR.strings.devices).uppercase()) {
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { if (chatModel.localUserCreated.value == true) {
if (connectedHost.value == null) { SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
} }
} }
@@ -162,26 +166,37 @@ fun DeviceNameField(
@Composable @Composable
private fun ConnectMobileViewLayout( private fun ConnectMobileViewLayout(
title: String, title: String?,
invitation: String?, invitation: String?,
deviceName: String?, deviceName: String?,
sessionCode: String?, sessionCode: String?,
port: String? port: String?,
staleQrCode: Boolean = false,
refreshQrCode: () -> Unit = {}
) { ) {
Column( Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
AppBarTitle(title) if (title != null) {
AppBarTitle(title)
}
SectionView { SectionView {
if (invitation != null && sessionCode == null && port != null) { if (invitation != null && sessionCode == null && port != null) {
QRCode( Box {
invitation, Modifier QRCode(
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) invitation, Modifier
.aspectRatio(1f) .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)) 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_on_port, port), textAlign = TextAlign.Center)
if (remember { controller.appPrefs.developerTools.state }.value) { if (remember { controller.appPrefs.developerTools.state }.value) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
@@ -218,6 +233,7 @@ private fun ConnectMobileViewLayout(
} }
} }
} }
SectionBottomSpacer()
} }
} }
@@ -237,55 +253,72 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
private fun showAddingMobileDevice(connecting: MutableState<Boolean>) { private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
ModalManager.start.showModalCloseable { close -> ModalManager.start.showModalCloseable { close ->
val invitation = rememberSaveable { mutableStateOf<String?>(null) } AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close)
val port = rememberSaveable { mutableStateOf<String?>(null) } }
val pairing = remember { chatModel.remoteHostPairing } }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode @Composable
else -> null fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState<Boolean>, connecting: MutableState<Boolean>, close: () -> Unit) {
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val port = rememberSaveable { mutableStateOf<String?>(null) }
val startRemoteHost = suspend {
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
} }
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ }
var cachedSessionCode by remember { mutableStateOf<String?>(null) } val pairing = remember { chatModel.remoteHostPairing }
if (cachedSessionCode == null && sessionCode != null) { val sessionCode = when (val state = pairing.value?.second) {
cachedSessionCode = sessionCode is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
} else -> null
val remoteDeviceName = pairing.value?.first?.hostDeviceName }
ConnectMobileViewLayout( /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), var cachedSessionCode by remember { mutableStateOf<String?>(null) }
invitation = invitation.value, if (cachedSessionCode == null && sessionCode != null) {
deviceName = remoteDeviceName, cachedSessionCode = sessionCode
sessionCode = cachedSessionCode, }
port = port.value val remoteDeviceName = pairing.value?.first?.hostDeviceName
) ConnectMobileViewLayout(
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } title = if (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { invitation = invitation.value,
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) { deviceName = remoteDeviceName,
close() sessionCode = cachedSessionCode,
} port = port.value,
} staleQrCode = staleQrCode.value,
KeyChangeEffect(pairing.value) { refreshQrCode = {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
withBGApi { withBGApi {
val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) if (chatController.stopRemoteHost(null)) {
if (r != null) { startRemoteHost()
connecting.value = true staleQrCode.value = false
invitation.value = r.second
port.value = r.third
chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting
} }
} }
onDispose { },
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { )
withBGApi { val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
chatController.stopRemoteHost(null) 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
} }
} }
} }
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ClickableText import chat.simplex.common.views.chat.item.ClickableText
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@@ -169,18 +170,20 @@ fun NetworkAndServersView(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
AppBarTitle(stringResource(MR.strings.network_and_servers)) AppBarTitle(stringResource(MR.strings.network_and_servers))
SectionView(generalGetString(MR.strings.settings_section_title_messages)) { if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) 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) { if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) { if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) 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) { 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)) 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)) Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} }
@@ -92,7 +92,6 @@ fun PrivacySettingsView(
chatModel.simplexLinkMode.value = it chatModel.simplexLinkMode.value = it
}) })
} }
SectionDividerSpaced()
val currentUser = chatModel.currentUser.value val currentUser = chatModel.currentUser.value
if (currentUser != null) { if (currentUser != null) {
@@ -142,39 +141,42 @@ fun PrivacySettingsView(
} }
} }
DeliveryReceiptsSection( if (!chatModel.desktopNoUserNoRemote) {
currentUser = currentUser, SectionDividerSpaced()
setOrAskSendReceiptsContacts = { enable -> DeliveryReceiptsSection(
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> currentUser = currentUser,
if (chat.chatInfo is ChatInfo.Direct) { setOrAskSendReceiptsContacts = { enable ->
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) 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 { } 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() SectionBottomSpacer()
} }
@@ -25,6 +25,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.SimpleXInfo
@@ -38,76 +39,39 @@ import kotlinx.coroutines.launch
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) { fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
val user = chatModel.currentUser.value val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false val stopped = chatModel.chatRunning.value == false
SettingsLayout(
if (user != null) { profile = user?.profile,
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } stopped,
SettingsLayout( chatModel.chatDbEncrypted.value == true,
profile = user.profile, remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
stopped, remember { chatModel.controller.appPrefs.notificationsMode.state },
chatModel.chatDbEncrypted.value == true, user?.displayName,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, setPerformLA = setPerformLA,
remember { chatModel.controller.appPrefs.notificationsMode.state }, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
user.displayName, showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
setPerformLA = setPerformLA, showSettingsModalWithSearch = { modalView ->
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, ModalManager.start.showCustomModal { close ->
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, val search = rememberSaveable { mutableStateOf("") }
showSettingsModalWithSearch = { modalView -> ModalView(
ModalManager.start.showCustomModal { close -> { close() },
val search = rememberSaveable { mutableStateOf("") } endButtons = {
ModalView( SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
{ close() }, },
endButtons = { content = { modalView(chatModel, search) })
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 = { withAuth = ::doWithAuth,
withApi { drawerState = drawerState,
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,
)
}
} }
val simplexTeamUri = val simplexTeamUri =
@@ -115,12 +79,12 @@ val simplexTeamUri =
@Composable @Composable
fun SettingsLayout( fun SettingsLayout(
profile: LocalProfile, profile: LocalProfile?,
stopped: Boolean, stopped: Boolean,
encrypted: Boolean, encrypted: Boolean,
passphraseSaved: Boolean, passphraseSaved: Boolean,
notificationsMode: State<NotificationsMode>, notificationsMode: State<NotificationsMode>,
userDisplayName: String, userDisplayName: String?,
setPerformLA: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -150,13 +114,22 @@ fun SettingsLayout(
AppBarTitle(stringResource(MR.strings.your_settings)) AppBarTitle(stringResource(MR.strings.your_settings))
SectionView(stringResource(MR.strings.settings_section_title_you)) { 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) } 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) if (profile != null) {
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) SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ChatPreferencesItem(showCustomModal, stopped = 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) { 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) 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 { } else {
@@ -176,10 +149,12 @@ fun SettingsLayout(
SectionDividerSpaced() SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) { 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_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_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) 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() 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) { private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) {
authenticate( authenticate(
title, title,
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ItemAction
@@ -56,7 +57,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
ModalManager.end.closeModals() ModalManager.end.closeModals()
} }
withBGApi { 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 -> removeUser = { user ->
@@ -565,6 +565,7 @@
<string name="your_settings">Your settings</string> <string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your SimpleX address</string> <string name="your_simplex_contact_address">Your SimpleX address</string>
<string name="your_chat_profiles">Your chat profiles</string> <string name="your_chat_profiles">Your chat profiles</string>
<string name="create_chat_profile">Create chat profile</string>
<string name="database_passphrase_and_export">Database passphrase &amp; export</string> <string name="database_passphrase_and_export">Database passphrase &amp; export</string>
<string name="about_simplex_chat">About SimpleX Chat</string> <string name="about_simplex_chat">About SimpleX Chat</string>
<string name="how_to_use_simplex_chat">How to use it</string> <string name="how_to_use_simplex_chat">How to use it</string>
@@ -1659,6 +1660,7 @@
<string name="unlink_desktop_question">Unlink desktop?</string> <string name="unlink_desktop_question">Unlink desktop?</string>
<string name="unlink_desktop">Unlink</string> <string name="unlink_desktop">Unlink</string>
<string name="disconnect_remote_host">Disconnect</string> <string name="disconnect_remote_host">Disconnect</string>
<string name="disconnect_remote_hosts">Disconnect mobiles</string>
<string name="remote_host_was_disconnected_toast"><![CDATA[Mobile <b>%s</b> was disconnected]]></string> <string name="remote_host_was_disconnected_toast"><![CDATA[Mobile <b>%s</b> was disconnected]]></string>
<string name="disconnect_desktop_question">Disconnect desktop?</string> <string name="disconnect_desktop_question">Disconnect desktop?</string>
<string name="only_one_device_can_work_at_the_same_time">Only one device can work at the same time</string> <string name="only_one_device_can_work_at_the_same_time">Only one device can work at the same time</string>
@@ -1689,6 +1691,8 @@
<string name="paste_desktop_address">Paste desktop address</string> <string name="paste_desktop_address">Paste desktop address</string>
<string name="desktop_device">Desktop</string> <string name="desktop_device">Desktop</string>
<string name="not_compatible">Not compatible!</string> <string name="not_compatible">Not compatible!</string>
<string name="refresh_qr_code">Refresh</string>
<string name="no_connected_mobile">No connected mobile</string>
<!-- Under development --> <!-- Under development -->
<string name="in_developing_title">Coming soon!</string> <string name="in_developing_title">Coming soon!</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480.433-164.5q-131.583 0-223.758-92.216Q164.5-348.932 164.5-480.082q0-131.149 92.175-223.284Q348.85-795.5 480.5-795.5q84 0 147.75 34.25T738.5-666.5v-129H796V-547H547.5v-57.5H713q-38.032-60.033-96.537-96.767Q557.959-738 480.539-738 372-738 297-663.015q-75 74.986-75 183.25 0 108.265 74.875 183.015Q371.75-222 480.331-222q82.298 0 150.734-47 68.435-47 95.623-124.5H786q-28.5 103-113.49 166-84.991 63-192.077 63Z"/></svg>

After

Width:  |  Height:  |  Size: 516 B

@@ -15,7 +15,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.TerminalView import chat.simplex.common.views.TerminalView
@@ -31,10 +30,30 @@ import java.io.File
val simplexWindowState = SimplexWindowState() val simplexWindowState = SimplexWindowState()
fun showApp() = application { fun showApp() = application {
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366, // Creates file if not exists; comes with proper defaults
// 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 state = getStoredWindowState()
val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp
val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp) 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 simplexWindowState.windowState = windowState
// Reload all strings in all @Composable's after language change at runtime // Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") { if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
@@ -124,7 +143,7 @@ fun showApp() = application {
var hiddenUntilRestart by remember { mutableStateOf(false) } var hiddenUntilRestart by remember { mutableStateOf(false) }
if (!hiddenUntilRestart) { if (!hiddenUntilRestart) {
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp) 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 { SimpleXTheme {
TerminalView(ChatModel) { hiddenUntilRestart = true } TerminalView(ChatModel) { hiddenUntilRestart = true }
} }
@@ -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))
@@ -136,7 +136,6 @@ private fun SendStateUpdates() {
.collect { call -> .collect { call ->
val state = call.callState.text val state = call.callState.text
val connInfo = call.connectionInfo val connInfo = call.connectionInfo
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
val description = call.encryptionStatus + connInfoText val description = call.encryptionStatus + connInfoText
chatModel.callCommand.add(WCallCommand.Description(state, description)) chatModel.callCommand.add(WCallCommand.Description(state, description))
@@ -0,0 +1,23 @@
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.ui.theme.DEFAULT_PADDING
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, 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()) 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)
}
}
+1 -6
View File
@@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package source-repository-package
type: git type: git
location: https://github.com/simplex-chat/simplexmq.git location: https://github.com/simplex-chat/simplexmq.git
tag: 757b7eec81341d8560a326deab303bb6fb6a26a3 tag: 90a8fc91d35c578c3b52ad296a6f1df715da2278
source-repository-package source-repository-package
type: git type: git
@@ -45,8 +45,3 @@ source-repository-package
type: git type: git
location: https://github.com/simplex-chat/android-support.git location: https://github.com/simplex-chat/android-support.git
tag: 9aa09f148089d6752ce563b14c2df1895718d806 tag: 9aa09f148089d6752ce563b14c2df1895718d806
source-repository-package
type: git
location: https://github.com/simplex-chat/network-transport.git
tag: 0013798272a683e35ca38d2fdaf480942311fba8
@@ -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.
@@ -0,0 +1,35 @@
# Paginated chat list
## Problem
The UI is requesting a fresh chat list when switching profiles.
The `getChatPreviews` operation consists of a multiple DB queries fetching everything to show every item in the chat list.
Together, they produce quite a bit of data for every contact the user ever had.
This induces heavy load to bring up the data, marshall everything to UI and present it.
The cost grows with time, starting from negligible, but becoming a massive drain and producing a noticable latency spike.
Users who have many active profiles with constant traffic in all of them have their experience worsening despite having only a portion of contacts active in the momemnt.
A simple "give next N after M" pagination is not enough here as chat list elements may break order by jumping to top when an `newChatItem` or alike message arrives.
## Solution
UI should turn to lazy list containers and pagination to delay loading yet invisible elements.
To operate in this mode it needs to know a total amount of elements and the elements should have persistent IDs, unified across all chat types.
A proposed API prepares a "spine" of such a list, to be paginated and loaded lazilly.
Then, an item loader can request individual list elements that happen to enter the view.
The current API element (of type `Chat`) contains a triple of `ChatInfo c`/`[CChatItem c]`/`ChatStats`.
For lazy loading only a chat reference (composed from the `chatInfo.type` and an appropriate numeric ID) is needed.
This is enough to populate list element with item proxies and inflate visible elements to a full `Chat` object.
Incoming update events then can trigger their usual re-ordering, which may in turn trigger element update, if it comes into view.
> `CRNewChatItem` only has `AChatItem` data, so it can resolve a proxy partially.
> A call for item details may bring in missing data like `ChatInfo` and `ChatStats` later or immediately.
So, the new API is two commands and no new types.
- `APIGetChatList -> CRChatList [ChatRef]`
- `APIGetChatDetails ChatRef -> CRChatDetails Chat`
The UI should distinguish proxy elements containing only references from full elements and use platform APIs to request details when a proxy comes into view.
+30
View File
@@ -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: []
+6 -6
View File
@@ -32,10 +32,10 @@ dependencies:
- filepath == 1.4.* - filepath == 1.4.*
- http-types == 0.12.* - http-types == 0.12.*
- http2 >= 4.2.2 && < 4.3 - http2 >= 4.2.2 && < 4.3
- memory == 0.18.* - memory >= 0.15 && < 0.19
- mtl == 2.3.* - mtl >= 2.2 && < 3
- network >= 3.1.2.7 && < 3.2 - network >= 3.1.2.7 && < 3.2
- network-transport == 0.5.6 - network-transport >= 0.5.6 && < 0.6
- optparse-applicative >= 0.15 && < 0.17 - optparse-applicative >= 0.15 && < 0.17
- process == 1.6.* - process == 1.6.*
- random >= 1.1 && < 1.3 - random >= 1.1 && < 1.3
@@ -45,14 +45,14 @@ dependencies:
- socks == 0.6.* - socks == 0.6.*
- sqlcipher-simple == 0.4.* - sqlcipher-simple == 0.4.*
- stm == 2.5.* - stm == 2.5.*
- template-haskell == 2.20.* - template-haskell >= 2.16 && < 2.21
- terminal == 0.2.* - terminal == 0.2.*
- text == 2.0.* - text >= 2.0 && < 3
- time == 1.9.* - time == 1.9.*
- tls >= 1.6.0 && < 1.7 - tls >= 1.6.0 && < 1.7
- unliftio == 0.2.* - unliftio == 0.2.*
- unliftio-core == 0.2.* - unliftio-core == 0.2.*
- zip == 2.0.* - zip >= 1.7 && < 2.1
flags: flags:
swift: swift:
@@ -86,7 +86,6 @@ export type ChatResponse =
| CRGroupUpdated | CRGroupUpdated
| CRUserContactLinkSubscribed | CRUserContactLinkSubscribed
| CRUserContactLinkSubError | CRUserContactLinkSubError
| CRNewContactConnection
| CRContactConnectionDeleted | CRContactConnectionDeleted
| CRMessageError | CRMessageError
| CRChatCmdError | CRChatCmdError
@@ -731,12 +730,6 @@ export interface CRUserContactLinkSubError extends CR {
chatError: ChatError chatError: ChatError
} }
export interface CRNewContactConnection extends CR {
type: "newContactConnection"
user: User
connection: PendingContactConnection
}
export interface CRContactConnectionDeleted extends CR { export interface CRContactConnectionDeleted extends CR {
type: "contactConnectionDeleted" type: "contactConnectionDeleted"
user: User user: User
+1 -2
View File
@@ -1,5 +1,5 @@
{ {
"https://github.com/simplex-chat/simplexmq.git"."757b7eec81341d8560a326deab303bb6fb6a26a3" = "0kqnxpyz8v43802fncqxdg6i2ni70yv7jg7a1nbkny1w937fwf40"; "https://github.com/simplex-chat/simplexmq.git"."90a8fc91d35c578c3b52ad296a6f1df715da2278" = "1yjixh6b2s1law3kh885fsbr1inv1r7iy4g9g2bn6j4ygdn8vlzy";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
@@ -7,5 +7,4 @@
"https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr";
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn"; "https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn";
"https://github.com/simplex-chat/network-transport.git"."0013798272a683e35ca38d2fdaf480942311fba8" = "0dnn62apgvc248df0m8ib7phrzn63wm0xs71xvlypv52j6cgwzkb";
} }
+43 -42
View File
@@ -124,6 +124,7 @@ library
Simplex.Chat.Migrations.M20231107_indexes Simplex.Chat.Migrations.M20231107_indexes
Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Migrations.M20231113_group_forward
Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Migrations.M20231114_remote_control
Simplex.Chat.Migrations.M20231126_remote_ctrl_address
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared
@@ -183,10 +184,10 @@ library
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -196,14 +197,14 @@ library
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -235,10 +236,10 @@ executable simplex-bot
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -249,14 +250,14 @@ executable simplex-bot
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -288,10 +289,10 @@ executable simplex-bot-advanced
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -302,14 +303,14 @@ executable simplex-bot-advanced
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -343,10 +344,10 @@ executable simplex-broadcast-bot
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -357,14 +358,14 @@ executable simplex-broadcast-bot
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -397,10 +398,10 @@ executable simplex-chat
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network ==3.1.* , network ==3.1.*
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -411,15 +412,15 @@ executable simplex-chat
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, websockets ==0.12.* , websockets ==0.12.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -455,10 +456,10 @@ executable simplex-directory-service
, filepath ==1.4.* , filepath ==1.4.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network >=3.1.2.7 && <3.2 , network >=3.1.2.7 && <3.2
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -469,14 +470,14 @@ executable simplex-directory-service
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
@@ -539,10 +540,10 @@ test-suite simplex-chat-test
, hspec ==2.11.* , hspec ==2.11.*
, http-types ==0.12.* , http-types ==0.12.*
, http2 >=4.2.2 && <4.3 , http2 >=4.2.2 && <4.3
, memory ==0.18.* , memory >=0.15 && <0.19
, mtl ==2.3.* , mtl >=2.2 && <3
, network ==3.1.* , network ==3.1.*
, network-transport ==0.5.6 , network-transport >=0.5.6 && <0.6
, optparse-applicative >=0.15 && <0.17 , optparse-applicative >=0.15 && <0.17
, process ==1.6.* , process ==1.6.*
, random >=1.1 && <1.3 , random >=1.1 && <1.3
@@ -554,14 +555,14 @@ test-suite simplex-chat-test
, socks ==0.6.* , socks ==0.6.*
, sqlcipher-simple ==0.4.* , sqlcipher-simple ==0.4.*
, stm ==2.5.* , stm ==2.5.*
, template-haskell ==2.20.* , template-haskell >=2.16 && <2.21
, terminal ==0.2.* , terminal ==0.2.*
, text ==2.0.* , text >=2.0 && <3
, time ==1.9.* , time ==1.9.*
, tls >=1.6.0 && <1.7 , tls >=1.6.0 && <1.7
, unliftio ==0.2.* , unliftio ==0.2.*
, unliftio-core ==0.2.* , unliftio-core ==0.2.*
, zip ==2.0.* , zip >=1.7 && <2.1
default-language: Haskell2010 default-language: Haskell2010
if flag(swift) if flag(swift)
cpp-options: -DswiftJSON cpp-options: -DswiftJSON
+317 -311
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -22,7 +22,7 @@ import qualified Data.Text as T
import qualified Database.SQLite3 as SQL import qualified Database.SQLite3 as SQL
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Messaging.Agent.Client (agentClientStore) 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 Simplex.Messaging.Util
import System.FilePath import System.FilePath
import UnliftIO.Directory import UnliftIO.Directory
+1 -1
View File
@@ -6,8 +6,8 @@ module Simplex.Chat.Bot.KnownContacts where
import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Int (Int64) import Data.Int (Int64)
import Data.Text (Text) import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Options.Applicative import Options.Applicative
import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Util (safeDecodeUtf8)
-1
View File
@@ -225,4 +225,3 @@ instance FromField CallState where
fromField = fromTextField_ decodeJSON fromField = fromTextField_ decodeJSON
$(J.deriveJSON defaultJSON ''RcvCallInvitation) $(J.deriveJSON defaultJSON ''RcvCallInvitation)
+35 -34
View File
@@ -1,5 +1,5 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE CPP #-} {-# LANGUAGE CPP #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-} {-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
@@ -41,6 +41,7 @@ import Data.String
import Data.Text (Text) import Data.Text (Text)
import Data.Time (NominalDiffTime, UTCTime) import Data.Time (NominalDiffTime, UTCTime)
import Data.Version (showVersion) import Data.Version (showVersion)
import Data.Word (Word16)
import Language.Haskell.TH (Exp, Q, runIO) import Language.Haskell.TH (Exp, Q, runIO)
import Numeric.Natural import Numeric.Natural
import qualified Paths_simplex_chat as SC import qualified Paths_simplex_chat as SC
@@ -426,19 +427,19 @@ data ChatCommand
| SetGroupTimedMessages GroupName (Maybe Int) | SetGroupTimedMessages GroupName (Maybe Int)
| SetLocalDeviceName Text | SetLocalDeviceName Text
| ListRemoteHosts | ListRemoteHosts
| StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host | 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 | SwitchRemoteHost (Maybe RemoteHostId) -- Switch current remote host
| StopRemoteHost RHKey -- ^ Shut down a running session | StopRemoteHost RHKey -- Shut down a running session
| DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data | DeleteRemoteHost RemoteHostId -- Unregister remote host and remove its data
| StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath} | StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath}
| GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile} | GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile}
| ConnectRemoteCtrl RCSignedInvitation -- ^ Connect new or existing controller via OOB data | ConnectRemoteCtrl RCSignedInvitation -- Connect new or existing controller via OOB data
| FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers | FindKnownRemoteCtrl -- Start listening for announcements from all existing controllers
| ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller | ConfirmRemoteCtrl RemoteCtrlId -- Confirm the connection with found controller
| VerifyRemoteCtrlSession Text -- ^ Verify remote controller session | VerifyRemoteCtrlSession Text -- Verify remote controller session
| ListRemoteCtrls | ListRemoteCtrls
| StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session | StopRemoteCtrl -- Stop listening for announcements or terminate an active session
| DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session | DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
| QuitChat | QuitChat
| ShowVersion | ShowVersion
| DebugLocks | DebugLocks
@@ -469,7 +470,7 @@ allowRemoteCommand = \case
APIGetNetworkConfig -> False APIGetNetworkConfig -> False
SetLocalDeviceName _ -> False SetLocalDeviceName _ -> False
ListRemoteHosts -> False ListRemoteHosts -> False
StartRemoteHost _ -> False StartRemoteHost {} -> False
SwitchRemoteHost {} -> False SwitchRemoteHost {} -> False
StoreRemoteFile {} -> False StoreRemoteFile {} -> False
GetRemoteFile {} -> False GetRemoteFile {} -> False
@@ -556,8 +557,8 @@ data ChatResponse
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection}
| CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection}
| CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan}
| CRSentConfirmation {user :: User} | CRSentConfirmation {user :: User, connection :: PendingContactConnection}
| CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
| CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember}
@@ -654,11 +655,10 @@ data ChatResponse
| CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
| CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
| CRNewContactConnection {user :: User, connection :: PendingContactConnection}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
| CRCurrentRemoteHost {remoteHost_ :: Maybe 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} | CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text}
| CRNewRemoteHost {remoteHost :: RemoteHostInfo} | CRNewRemoteHost {remoteHost :: RemoteHostInfo}
| CRRemoteHostConnected {remoteHost :: RemoteHostInfo} | CRRemoteHostConnected {remoteHost :: RemoteHostInfo}
@@ -1072,32 +1072,33 @@ throwDBError = throwError . ChatErrorDatabase
-- TODO review errors, some of it can be covered by HTTP2 errors -- TODO review errors, some of it can be covered by HTTP2 errors
data RemoteHostError data RemoteHostError
= RHEMissing -- ^ No remote session matches this identifier = RHEMissing -- No remote session matches this identifier
| RHEInactive -- ^ A session exists, but not active | RHEInactive -- A session exists, but not active
| RHEBusy -- ^ A session is already running | RHEBusy -- A session is already running
| RHETimeout | RHETimeout
| RHEBadState -- ^ Illegal state transition | RHEBadState -- Illegal state transition
| RHEBadVersion {appVersion :: AppVersion} | 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? | RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected?
| RHEProtocolError RemoteProtocolError | RHEProtocolError RemoteProtocolError
deriving (Show, Exception) deriving (Show, Exception)
data RemoteHostStopReason data RemoteHostStopReason
= RHSRConnectionFailed ChatError = RHSRConnectionFailed {chatError :: ChatError}
| RHSRCrashed ChatError | RHSRCrashed {chatError :: ChatError}
| RHSRDisconnected | RHSRDisconnected
deriving (Show, Exception) deriving (Show, Exception)
-- TODO review errors, some of it can be covered by HTTP2 errors -- TODO review errors, some of it can be covered by HTTP2 errors
data RemoteCtrlError data RemoteCtrlError
= RCEInactive -- ^ No session is running = RCEInactive -- No session is running
| RCEBadState -- ^ A session is in a wrong state for the current operation | RCEBadState -- A session is in a wrong state for the current operation
| RCEBusy -- ^ A session is already running | RCEBusy -- A session is already running
| RCETimeout | RCETimeout
| RCENoKnownControllers -- ^ No previously-contacted controllers to discover | RCENoKnownControllers -- No previously-contacted controllers to discover
| RCEBadController -- ^ Attempting to confirm a found controller with another ID | RCEBadController -- Attempting to confirm a found controller with another ID
| RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller | -- | A session disconnected by a controller
RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text}
| RCEBadInvitation | RCEBadInvitation
| RCEBadVersion {appVersion :: AppVersion} | RCEBadVersion {appVersion :: AppVersion}
| RCEHTTP2Error {http2Error :: Text} -- TODO currently not used | RCEHTTP2Error {http2Error :: Text} -- TODO currently not used
@@ -1105,9 +1106,9 @@ data RemoteCtrlError
deriving (Show, Exception) deriving (Show, Exception)
data RemoteCtrlStopReason data RemoteCtrlStopReason
= RCSRDiscoveryFailed ChatError = RCSRDiscoveryFailed {chatError :: ChatError}
| RCSRConnectionFailed ChatError | RCSRConnectionFailed {chatError :: ChatError}
| RCSRSetupFailed ChatError | RCSRSetupFailed {chatError :: ChatError}
| RCSRDisconnected | RCSRDisconnected
deriving (Show, Exception) deriving (Show, Exception)
@@ -1223,8 +1224,8 @@ toView event = do
session <- asks remoteCtrlSession session <- asks remoteCtrlSession
atomically $ atomically $
readTVar session >>= \case readTVar session >>= \case
Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event -> Just (_, RCSessionConnected {remoteOutputQ})
writeTBQueue remoteOutputQ event | allowRemoteEvent event -> writeTBQueue remoteOutputQ event
-- TODO potentially, it should hold some events while connecting -- TODO potentially, it should hold some events while connecting
_ -> writeTBQueue localQ (Nothing, Nothing, event) _ -> writeTBQueue localQ (Nothing, Nothing, event)
+3 -3
View File
@@ -35,9 +35,9 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController
runSimplexChat ChatOpts {maintenance} u cc chat runSimplexChat ChatOpts {maintenance} u cc chat
| maintenance = wait =<< async (chat u cc) | maintenance = wait =<< async (chat u cc)
| otherwise = do | otherwise = do
a1 <- runReaderT (startChatController True True True) cc a1 <- runReaderT (startChatController True True True) cc
a2 <- async $ chat u cc a2 <- async $ chat u cc
waitEither_ a1 a2 waitEither_ a1 a2
sendChatCmdStr :: ChatController -> String -> IO ChatResponse sendChatCmdStr :: ChatController -> String -> IO ChatResponse
sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc
+2 -2
View File
@@ -6,8 +6,8 @@ module Simplex.Chat.Files where
import Control.Monad.IO.Class import Control.Monad.IO.Class
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Messaging.Util (ifM) import Simplex.Messaging.Util (ifM)
import System.FilePath (splitExtensions, combine) import System.FilePath (combine, splitExtensions)
import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist) import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getHomeDirectory, getTemporaryDirectory)
uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath
uniqueCombine fPath fName = tryCombine (0 :: Int) uniqueCombine fPath fName = tryCombine (0 :: Int)
+18 -16
View File
@@ -19,7 +19,7 @@ import qualified Data.Attoparsec.Text as A
import Data.Char (isDigit, isPunctuation) import Data.Char (isDigit, isPunctuation)
import Data.Either (fromRight) import Data.Either (fromRight)
import Data.Functor (($>)) import Data.Functor (($>))
import Data.List (intercalate, foldl') import Data.List (foldl', intercalate)
import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe, isNothing) import Data.Maybe (fromMaybe, isNothing)
@@ -85,16 +85,18 @@ newtype FormatColor = FormatColor Color
deriving (Eq, Show) deriving (Eq, Show)
instance FromJSON FormatColor where instance FromJSON FormatColor where
parseJSON = J.withText "FormatColor" $ fmap FormatColor . \case parseJSON =
"red" -> pure Red J.withText "FormatColor" $
"green" -> pure Green fmap FormatColor . \case
"blue" -> pure Blue "red" -> pure Red
"yellow" -> pure Yellow "green" -> pure Green
"cyan" -> pure Cyan "blue" -> pure Blue
"magenta" -> pure Magenta "yellow" -> pure Yellow
"black" -> pure Black "cyan" -> pure Cyan
"white" -> pure White "magenta" -> pure Magenta
unexpected -> fail $ "unexpected FormatColor: " <> show unexpected "black" -> pure Black
"white" -> pure White
unexpected -> fail $ "unexpected FormatColor: " <> show unexpected
instance ToJSON FormatColor where instance ToJSON FormatColor where
toJSON (FormatColor c) = case c of toJSON (FormatColor c) = case c of
@@ -167,14 +169,14 @@ markdownP = mconcat <$> A.many' fragmentP
md :: Char -> Format -> Text -> Markdown md :: Char -> Format -> Text -> Markdown
md c f s md c f s
| T.null s || T.head s == ' ' || T.last 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 | otherwise = markdown f s
secretP :: Parser Markdown secretP :: Parser Markdown
secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#') secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
secret :: Text -> Text -> Text -> Markdown secret :: Text -> Text -> Text -> Markdown
secret b s a secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' = | 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 | otherwise = markdown Secret $ T.init ss
where where
ss = b <> s <> a ss = b <> s <> a
@@ -215,9 +217,9 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD s wordMD s
| T.null s = unmarked s | T.null s = unmarked s
| isUri s = | isUri s =
let t = T.takeWhileEnd isPunctuation s let t = T.takeWhileEnd isPunctuation s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
in if T.null t then uri else uri :|: unmarked t in if T.null t then uri else uri :|: unmarked t
| isEmail s = markdown Email s | isEmail s = markdown Email s
| otherwise = unmarked s | otherwise = unmarked s
uriMarkdown s = case strDecode $ encodeUtf8 s of uriMarkdown s = case strDecode $ encodeUtf8 s of
+2 -3
View File
@@ -11,7 +11,6 @@
{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Messages where module Simplex.Chat.Messages where
@@ -44,7 +43,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta
import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String 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.Protocol (MsgBody)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
@@ -345,7 +344,7 @@ contactTimedTTL Contact {mergedPreferences = ContactUserPreferences {timedMessag
| forUser enabled && forContact enabled = Just ttl | forUser enabled && forContact enabled = Just ttl
| otherwise = Nothing | otherwise = Nothing
where where
TimedMessagesPreference {ttl} = userPreference.preference TimedMessagesPreference {ttl} = userPreference.preference
groupTimedTTL :: GroupInfo -> Maybe (Maybe Int) groupTimedTTL :: GroupInfo -> Maybe (Maybe Int)
groupTimedTTL GroupInfo {fullGroupPreferences = FullGroupPreferences {timedMessages = TimedMessagesGroupPreference {enable, ttl}}} groupTimedTTL GroupInfo {fullGroupPreferences = FullGroupPreferences {timedMessages = TimedMessagesGroupPreference {enable, ttl}}}
+1 -1
View File
@@ -311,7 +311,7 @@ profileToText Profile {displayName, fullName} = displayName <> optionalFullName
msgIntegrityError :: MsgErrorType -> Text msgIntegrityError :: MsgErrorType -> Text
msgIntegrityError = \case msgIntegrityError = \case
MsgSkipped fromId toId -> MsgSkipped fromId toId ->
"skipped message ID " <> tshow fromId ("skipped message ID " <> tshow fromId)
<> if fromId == toId then "" else ".." <> tshow toId <> if fromId == toId then "" else ".." <> tshow toId
MsgBadId msgId -> "unexpected message ID " <> tshow msgId MsgBadId msgId -> "unexpected message ID " <> tshow msgId
MsgBadHash -> "incorrect message hash" MsgBadHash -> "incorrect message hash"
@@ -46,9 +46,9 @@ data SndConnEvent
| SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef} | SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef}
deriving (Show) deriving (Show)
data RcvDirectEvent = data RcvDirectEvent
-- RDEProfileChanged {...} = -- RDEProfileChanged {...}
RDEContactDeleted RDEContactDeleted
deriving (Show) deriving (Show)
-- platform-specific JSON encoding (used in API) -- platform-specific JSON encoding (used in API)
@@ -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;
|]
@@ -537,6 +537,10 @@ CREATE TABLE remote_hosts(
id_key BLOB NOT NULL, -- long-term/identity signing key id_key BLOB NOT NULL, -- long-term/identity signing key
host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected
host_dh_pub BLOB NOT NULL -- last session DH key host_dh_pub BLOB NOT NULL -- last session DH key
,
bind_addr TEXT,
bind_iface TEXT,
bind_port INTEGER
); );
CREATE TABLE remote_controllers( CREATE TABLE remote_controllers(
-- e.g., desktops known to a mobile app -- e.g., desktops known to a mobile app
+4 -5
View File
@@ -4,13 +4,12 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fobject-code #-} {-# OPTIONS_GHC -fobject-code #-}
module Simplex.Chat.Mobile where module Simplex.Chat.Mobile where
import Control.Concurrent.STM import Control.Concurrent.STM
import Control.Exception (catch, SomeException) import Control.Exception (SomeException, catch)
import Control.Monad.Except import Control.Monad.Except
import Control.Monad.Reader import Control.Monad.Reader
import qualified Data.Aeson as J import qualified Data.Aeson as J
@@ -31,7 +30,7 @@ import Foreign.C.Types (CInt (..))
import Foreign.Ptr import Foreign.Ptr
import Foreign.StablePtr import Foreign.StablePtr
import Foreign.Storable (poke) import Foreign.Storable (poke)
import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding)
import Simplex.Chat import Simplex.Chat
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList)
@@ -219,7 +218,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do
ExceptT $ ExceptT $
(first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations)
`catch` (pure . checkDBError) `catch` (pure . checkDBError)
`catchAll` (pure . dbError) `catchAll` (pure . dbError)
where where
checkDBError e = case sqlError e of checkDBError e = case sqlError e of
DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile
@@ -233,7 +232,7 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do
handleErr :: IO () -> IO String handleErr :: IO () -> IO String
handleErr a = (a $> "") `catch` (pure . show @SomeException) handleErr a = (a $> "") `catch` (pure . show @SomeException)
chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString
chatSendCmd cc = chatSendRemoteCmd cc Nothing chatSendCmd cc = chatSendRemoteCmd cc Nothing
+1 -1
View File
@@ -6,8 +6,8 @@ import qualified Data.ByteString as B
import Data.ByteString.Internal (ByteString (..), memcpy) import Data.ByteString.Internal (ByteString (..), memcpy)
import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy as LB
import qualified Data.ByteString.Lazy.Internal as LB import qualified Data.ByteString.Lazy.Internal as LB
import Foreign.C (CInt, CString)
import Foreign import Foreign
import Foreign.C (CInt, CString)
type CJSONString = CString type CJSONString = CString
+8 -8
View File
@@ -1,12 +1,12 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
module Simplex.Chat.Mobile.WebRTC ( module Simplex.Chat.Mobile.WebRTC
cChatEncryptMedia, ( cChatEncryptMedia,
cChatDecryptMedia, cChatDecryptMedia,
chatEncryptMedia, chatEncryptMedia,
chatDecryptMedia, chatDecryptMedia,
reservedSize, reservedSize,
) where ) where
import Control.Monad import Control.Monad
import Control.Monad.Except import Control.Monad.Except
@@ -21,8 +21,8 @@ import Data.Either (fromLeft)
import Data.Word (Word8) import Data.Word (Word8)
import Foreign.C (CInt, CString, newCAString) import Foreign.C (CInt, CString, newCAString)
import Foreign.Ptr (Ptr) import Foreign.Ptr (Ptr)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.Shared
import qualified Simplex.Messaging.Crypto as C
cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
cChatEncryptMedia = cTransformMedia chatEncryptMedia cChatEncryptMedia = cTransformMedia chatEncryptMedia
-1
View File
@@ -206,7 +206,6 @@ chatOptsP appDir defaultDbFileName = do
optional $ optional $
strOption strOption
( long "device-name" ( long "device-name"
<> short 'e'
<> metavar "DEVICE" <> metavar "DEVICE"
<> help "Device name to use in connections with remote hosts and controller" <> help "Device name to use in connections with remote hosts and controller"
) )
+4 -4
View File
@@ -18,10 +18,10 @@ generateRandomProfile = do
pickNoun adjective n pickNoun adjective n
| n == 0 = pick nouns | n == 0 = pick nouns
| otherwise = do | otherwise = do
noun <- pick nouns noun <- pick nouns
if noun == adjective if noun == adjective
then pickNoun adjective (n - 1) then pickNoun adjective (n - 1)
else pure noun else pure noun
adjectives :: [Text] adjectives :: [Text]
adjectives = adjectives =
-1
View File
@@ -13,7 +13,6 @@
{-# LANGUAGE StrictData #-} {-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Protocol where module Simplex.Chat.Protocol where
+61 -51
View File
@@ -26,13 +26,14 @@ import qualified Data.ByteString.Base64.URL as B64U
import Data.ByteString.Builder (Builder) import Data.ByteString.Builder (Builder)
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.Functor (($>)) 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 qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust) import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Word (Word32) import Data.Word (Word16, Word32)
import qualified Network.HTTP.Types as N import qualified Network.HTTP.Types as N
import Network.HTTP2.Server (responseStreaming) import Network.HTTP2.Server (responseStreaming)
import qualified Paths_simplex_chat as SC import qualified Paths_simplex_chat as SC
@@ -97,24 +98,26 @@ discoveryTimeout = 60000000
getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient
getRemoteHostClient rhId = do getRemoteHostClient rhId = do
sessions <- asks remoteHostSessions sessions <- asks remoteHostSessions
liftIOEither . atomically $ TM.lookup rhKey sessions >>= \case liftIOEither . atomically $
Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient TM.lookup rhKey sessions >>= \case
Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
where where
rhKey = RHId rhId rhKey = RHId rhId
withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a
withRemoteHostSession rhKey sseq f = do withRemoteHostSession rhKey sseq f = do
sessions <- asks remoteHostSessions sessions <- asks remoteHostSessions
r <- atomically $ r <-
TM.lookup rhKey sessions >>= \case atomically $
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing TM.lookup rhKey sessions >>= \case
Just (stateSeq, state) Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
| stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState Just (stateSeq, state)
| otherwise -> case f state of | stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions | otherwise -> case f state of
Left ce -> pure $ Left ce Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions
Left ce -> pure $ Left ce
liftEither r liftEither r
-- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId' -- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId'
@@ -133,8 +136,8 @@ setNewRemoteHostId sseq rhId = do
where where
err = pure . Left . ChatErrorRemoteHost RHNew err = pure . Left . ChatErrorRemoteHost RHNew
startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation) startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> Maybe RCCtrlAddress -> Maybe Word16 -> m (NonEmpty RCCtrlAddress, Maybe RemoteHostInfo, RCSignedInvitation)
startRemoteHost rh_ = do startRemoteHost rh_ rcAddrPrefs_ port_ = do
(rhKey, multicast, remoteHost_, pairing) <- case rh_ of (rhKey, multicast, remoteHost_, pairing) <- case rh_ of
Just (rhId, multicast) -> do Just (rhId, multicast) -> do
rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId
@@ -142,19 +145,20 @@ startRemoteHost rh_ = do
Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing
sseq <- startRemoteHostSession rhKey sseq <- startRemoteHostSession rhKey
ctrlAppInfo <- mkCtrlAppInfo 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 cmdOk <- newEmptyTMVarIO
rhsWaitSession <- async $ do rhsWaitSession <- async $ do
rhKeyVar <- newTVarIO rhKey rhKeyVar <- newTVarIO rhKey
atomically $ takeTMVar cmdOk 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_} let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_}
withRemoteHostSession rhKey sseq $ \case withRemoteHostSession rhKey sseq $ \case
RHSessionStarting -> RHSessionStarting ->
let inv = decodeLatin1 $ strEncode invitation let inv = decodeLatin1 $ strEncode invitation
in Right ((), RHSessionConnecting inv rhs) in Right ((), RHSessionConnecting inv rhs)
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState
(remoteHost_, invitation) <$ atomically (putTMVar cmdOk ()) (localAddrs, remoteHost_, invitation) <$ atomically (putTMVar cmdOk ())
where where
mkCtrlAppInfo = do mkCtrlAppInfo = do
deviceName <- chatReadVar localDeviceName deviceName <- chatReadVar localDeviceName
@@ -167,16 +171,18 @@ startRemoteHost rh_ = do
when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding
pure hostInfo pure hostInfo
handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a
handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do handleConnectError rhKey sessSeq action =
logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err action `catchChatError` \err -> do
cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err
throwError err cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey
throwError err
handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m () handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m ()
handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do handleHostError sessSeq rhKeyVar action =
logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err action `catchChatError` \err -> do
readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err
waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m () readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err))
waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do 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 (sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars
let sessionCode = verificationCode sessId let sessionCode = verificationCode sessId
withRemoteHostSession rhKey sseq $ \case withRemoteHostSession rhKey sseq $ \case
@@ -190,7 +196,7 @@ startRemoteHost rh_ = do
withRemoteHostSession rhKey sseq $ \case withRemoteHostSession rhKey sseq $ \case
RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs') RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs')
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState _ -> 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 let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew
when (rhKey' /= rhKey) $ do when (rhKey' /= rhKey) $ do
atomically $ writeTVar rhKeyVar rhKey' atomically $ writeTVar rhKeyVar rhKey'
@@ -205,17 +211,17 @@ startRemoteHost rh_ = do
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState _ -> Left $ ChatErrorRemoteHost rhKey RHEBadState
chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host
toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}} toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}}
upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo
upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do 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_ KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_
case rhi_ of case rhi_ of
Nothing -> do Nothing -> do
storePath <- liftIO randomStorePath 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 setNewRemoteHostId sseq remoteHostId
pure $ remoteHostInfo rh $ Just state pure $ remoteHostInfo rh $ Just state
Just rhi@RemoteHostInfo {remoteHostId} -> do 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} pure (rhi :: RemoteHostInfo) {sessionState = Just state}
onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m () onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m ()
onDisconnected rhKey sseq = do onDisconnected rhKey sseq = do
@@ -250,14 +256,15 @@ cancelRemoteHostSession :: ChatMonad m => Maybe (SessionSeq, RemoteHostStopReaso
cancelRemoteHostSession handlerInfo_ rhKey = do cancelRemoteHostSession handlerInfo_ rhKey = do
sessions <- asks remoteHostSessions sessions <- asks remoteHostSessions
crh <- asks currentRemoteHost crh <- asks currentRemoteHost
deregistered <- atomically $ deregistered <-
TM.lookup rhKey sessions >>= \case atomically $
Nothing -> pure Nothing TM.lookup rhKey sessions >>= \case
Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler Nothing -> pure Nothing
Just (_, rhs) -> do Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler
TM.delete rhKey sessions Just (_, rhs) -> do
modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH TM.delete rhKey sessions
pure $ Just rhs modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH
pure $ Just rhs
forM_ deregistered $ \session -> do forM_ deregistered $ \session -> do
liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow) liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow)
forM_ (snd <$> handlerInfo_) $ \rhStopReason -> forM_ (snd <$> handlerInfo_) $ \rhStopReason ->
@@ -312,8 +319,8 @@ switchRemoteHost rhId_ = do
rhi_ <$ chatWriteVar currentRemoteHost rhId_ rhi_ <$ chatWriteVar currentRemoteHost rhId_
remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo
remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState = remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_} sessionState =
RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState} RemoteHostInfo {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_, sessionState}
deleteRemoteHost :: ChatMonad m => RemoteHostId -> m () deleteRemoteHost :: ChatMonad m => RemoteHostId -> m ()
deleteRemoteHost rhId = do deleteRemoteHost rhId = do
@@ -401,9 +408,10 @@ findKnownRemoteCtrl = do
(RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <-
timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings
ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing)
rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case rc <-
Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case
Just rc -> pure rc Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl"
Just rc -> pure rc
atomically $ putTMVar foundCtrl (rc, inv) atomically $ putTMVar foundCtrl (rc, inv)
let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_ let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_
toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible} toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible}
@@ -422,7 +430,7 @@ confirmRemoteCtrl rcId = do
pure $ Right (sseq, action, foundCtrl) pure $ Right (sseq, action, foundCtrl)
_ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState _ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState
uninterruptibleCancel listener uninterruptibleCancel listener
(RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found (RemoteCtrl {remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found
unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController
connectRemoteCtrl verifiedInv sseq >>= \case connectRemoteCtrl verifiedInv sseq >>= \case
(Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl" (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 :: ChatMonad m => Maybe (SessionSeq, RemoteCtrlStopReason) -> m ()
cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do
var <- asks remoteCtrlSession var <- asks remoteCtrlSession
session_ <- atomically $ readTVar var >>= \case session_ <-
Nothing -> pure Nothing atomically $
Just (oldSeq, _) | maybe False (/= oldSeq) (fst <$> handlerInfo_) -> pure Nothing readTVar var >>= \case
Just (_, s) -> Just s <$ writeTVar var Nothing Nothing -> pure Nothing
Just (oldSeq, _) | (maybe False ((oldSeq /=) . fst) handlerInfo_) -> pure Nothing
Just (_, s) -> Just s <$ writeTVar var Nothing
forM_ session_ $ \session -> do forM_ session_ $ \session -> do
liftIO $ cancelRemoteCtrl handlingError session liftIO $ cancelRemoteCtrl handlingError session
forM_ (snd <$> handlerInfo_) $ \rcStopReason -> forM_ (snd <$> handlerInfo_) $ \rcStopReason ->
+1 -1
View File
@@ -11,7 +11,7 @@ module Simplex.Chat.Remote.AppVersion
compatibleAppVersion, compatibleAppVersion,
isAppCompatible, isAppCompatible,
) )
where where
import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Aeson as J import qualified Data.Aeson as J
+2 -4
View File
@@ -6,10 +6,8 @@ import Network.Socket
#include <HsNet.h> #include <HsNet.h>
{- | Toggle multicast group membership. -- | Toggle multicast group membership.
-- NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups.
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 :: Socket -> HostAddress -> Bool -> IO (Either CInt ())
setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do
#{poke struct ip_mreq, imr_multiaddr} mReqPtr group #{poke struct ip_mreq, imr_multiaddr} mReqPtr group
+7 -7
View File
@@ -6,8 +6,8 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Chat.Remote.Protocol where module Simplex.Chat.Remote.Protocol where
@@ -41,16 +41,16 @@ import Simplex.FileTransfer.Description (FileDigest (..))
import Simplex.Messaging.Agent.Client (agentDRG) import Simplex.Messaging.Agent.Client (agentDRG)
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..)) 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.Encoding
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag) import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
import Simplex.Messaging.Transport.Buffer (getBuffered) import Simplex.Messaging.Transport.Buffer (getBuffered)
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk) import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow) import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow)
import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode)
import Simplex.RemoteControl.Client (xrcpBlockSize) import Simplex.RemoteControl.Client (xrcpBlockSize)
import qualified Simplex.RemoteControl.Client as RC import qualified Simplex.RemoteControl.Client as RC
import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode)
import System.FilePath (takeFileName, (</>)) import System.FilePath (takeFileName, (</>))
import UnliftIO import UnliftIO
@@ -64,10 +64,10 @@ data RemoteCommand
data RemoteResponse data RemoteResponse
= RRChatResponse {chatResponse :: ChatResponse} = RRChatResponse {chatResponse :: ChatResponse}
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout | RRChatEvent {chatEvent :: Maybe ChatResponse} -- 'Nothing' on poll timeout
| RRFileStored {filePath :: String} | RRFileStored {filePath :: String}
| RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest | 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) deriving (Show)
-- Force platform-independent encoding as the types aren't UI-visible -- Force platform-independent encoding as the types aren't UI-visible
@@ -126,7 +126,7 @@ remoteStoreFile c localPath fileName = do
r -> badResponse r r -> badResponse r
remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO () 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 sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case
(getChunk, RRFile {fileSize, fileDigest}) -> do (getChunk, RRFile {fileSize, fileDigest}) -> do
-- TODO we could optimize by checking size and hash before receiving the file -- 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 -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do 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) req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd)
HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing
(header, getNext) <- parseDecryptHTTP2Body encryption response respBody (header, getNext) <- parseDecryptHTTP2Body encryption response respBody
+3 -3
View File
@@ -5,15 +5,15 @@ module Simplex.Chat.Remote.Transport where
import Control.Monad import Control.Monad
import Control.Monad.Except import Control.Monad.Except
import Data.ByteString.Builder (Builder, byteString)
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import Data.ByteString.Builder (Builder, byteString)
import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy as LB
import Data.Word (Word32) import Data.Word (Word32)
import Simplex.FileTransfer.Description (FileDigest (..))
import Simplex.Chat.Remote.Types 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 as C
import qualified Simplex.Messaging.Crypto.Lazy as LC import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile)
import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding
import Simplex.Messaging.Util (liftEitherError, liftEitherWith) import Simplex.Messaging.Util (liftEitherError, liftEitherWith)
import Simplex.RemoteControl.Types (RCErrorType (..)) import Simplex.RemoteControl.Types (RCErrorType (..))
+20 -14
View File
@@ -18,16 +18,17 @@ import qualified Data.Aeson.TH as J
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import Data.Int (Int64) import Data.Int (Int64)
import Data.Text (Text) import Data.Text (Text)
import Data.Word (Word16)
import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Types (verificationCode) import Simplex.Chat.Types (verificationCode)
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile)
import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret) import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Transport (TLS (..))
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
import Simplex.RemoteControl.Client import Simplex.RemoteControl.Client
import Simplex.RemoteControl.Types import Simplex.RemoteControl.Types
import Simplex.Messaging.Crypto.File (CryptoFile)
import Simplex.Messaging.Transport (TLS (..))
data RemoteHostClient = RemoteHostClient data RemoteHostClient = RemoteHostClient
{ hostEncoding :: PlatformEncoding, { hostEncoding :: PlatformEncoding,
@@ -48,13 +49,13 @@ data RemoteCrypto = RemoteCrypto
data RemoteSignatures data RemoteSignatures
= RSSign = RSSign
{ idPrivKey :: C.PrivateKeyEd25519, { idPrivKey :: C.PrivateKeyEd25519,
sessPrivKey :: C.PrivateKeyEd25519 sessPrivKey :: C.PrivateKeyEd25519
} }
| RSVerify | RSVerify
{ idPubKey :: C.PublicKeyEd25519, { idPubKey :: C.PublicKeyEd25519,
sessPubKey :: C.PublicKeyEd25519 sessPubKey :: C.PublicKeyEd25519
} }
type SessionSeq = Int type SessionSeq = Int
@@ -71,12 +72,12 @@ data RemoteHostSession
| RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession}
| RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession} | RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession}
| RHSessionConnected | RHSessionConnected
{ rchClient :: RCHostClient, { rchClient :: RCHostClient,
tls :: TLS, tls :: TLS,
rhClient :: RemoteHostClient, rhClient :: RemoteHostClient,
pollAction :: Async (), pollAction :: Async (),
storePath :: FilePath storePath :: FilePath
} }
data RemoteHostSessionState data RemoteHostSessionState
= RHSStarting = RHSStarting
@@ -128,6 +129,8 @@ data RemoteHost = RemoteHost
{ remoteHostId :: RemoteHostId, { remoteHostId :: RemoteHostId,
hostDeviceName :: Text, hostDeviceName :: Text,
storePath :: FilePath, storePath :: FilePath,
bindAddress_ :: Maybe RCCtrlAddress,
bindPort_ :: Maybe Word16,
hostPairing :: RCHostPairing hostPairing :: RCHostPairing
} }
@@ -136,6 +139,8 @@ data RemoteHostInfo = RemoteHostInfo
{ remoteHostId :: RemoteHostId, { remoteHostId :: RemoteHostId,
hostDeviceName :: Text, hostDeviceName :: Text,
storePath :: FilePath, storePath :: FilePath,
bindAddress_ :: Maybe RCCtrlAddress,
bindPort_ :: Maybe Word16,
sessionState :: Maybe RemoteHostSessionState sessionState :: Maybe RemoteHostSessionState
} }
deriving (Show) deriving (Show)
@@ -158,6 +163,7 @@ data PlatformEncoding
deriving (Show, Eq) deriving (Show, Eq)
localEncoding :: PlatformEncoding localEncoding :: PlatformEncoding
#if defined(darwin_HOST_OS) && defined(swiftJSON) #if defined(darwin_HOST_OS) && defined(swiftJSON)
localEncoding = PESwift localEncoding = PESwift
#else #else
+20 -19
View File
@@ -4,7 +4,6 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Connections module Simplex.Chat.Store.Connections
@@ -25,11 +24,11 @@ import Data.Text (Text)
import Data.Time.Clock (UTCTime (..)) import Data.Time.Clock (UTCTime (..))
import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Files import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared import Simplex.Chat.Store.Shared
import Simplex.Chat.Protocol
import Simplex.Chat.Types import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Protocol (ConnId)
@@ -157,8 +156,9 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity)
getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do
connId_ <- maybeFirstRow fromOnly $ connId_ <-
DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) 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_ maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_
-- search connection for connection plan: -- search connection for connection plan:
@@ -167,21 +167,22 @@ getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) =
-- deleted connections are filtered out to allow re-connecting via same contact address -- deleted connections are filtered out to allow re-connecting via same contact address
getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity)
getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do
connId_ <- maybeFirstRow fromOnly $ connId_ <-
DB.query maybeFirstRow fromOnly $
db DB.query
[sql| db
SELECT agent_conn_id FROM ( [sql|
SELECT SELECT agent_conn_id FROM (
agent_conn_id, SELECT
(CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord agent_conn_id,
FROM connections (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord
WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? FROM connections
ORDER BY conn_ord DESC, created_at DESC WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ?
LIMIT 1 ORDER BY conn_ord DESC, created_at DESC
) LIMIT 1
|] )
(userId, cReqHash1, cReqHash2, ConnDeleted) |]
(userId, cReqHash1, cReqHash2, ConnDeleted)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_
getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity])
+10 -14
View File
@@ -1,13 +1,12 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Direct module Simplex.Chat.Store.Direct
@@ -310,14 +309,14 @@ deleteUnusedProfile_ db userId profileId =
updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact
updateContactProfile db user@User {userId} c p' updateContactProfile db user@User {userId} c p'
| displayName == newName = do | displayName == newName = do
liftIO $ updateContactProfile_ db userId profileId p' liftIO $ updateContactProfile_ db userId profileId p'
pure c {profile, mergedPreferences} pure c {profile, mergedPreferences}
| otherwise = | otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId contactId localDisplayName ldn currentTs updateContact_ db userId contactId localDisplayName ldn currentTs
pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} pure $ Right c {localDisplayName = ldn, profile, mergedPreferences}
where where
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c
Profile {displayName = newName, preferences} = p' Profile {displayName = newName, preferences} = p'
@@ -784,10 +783,8 @@ updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO ()
updateConnectionStatus db Connection {connId} connStatus = do updateConnectionStatus db Connection {connId} connStatus = do
currentTs <- getCurrentTime currentTs <- getCurrentTime
if connStatus == ConnReady if connStatus == ConnReady
then then DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId)
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)
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.Connection -> User -> Int64 -> ChatSettings -> IO ()
updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} =
@@ -816,4 +813,3 @@ resetContactConnInitiated db User {userId} Connection {connId} = do
WHERE user_id = ? AND connection_id = ? WHERE user_id = ? AND connection_id = ?
|] |]
(updatedAt, userId, connId) (updatedAt, userId, connId)
+19 -18
View File
@@ -109,7 +109,7 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..))
getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer]
getLiveSndFileTransfers db User {userId} = do getLiveSndFileTransfers db User {userId} = do
cutoffTs <- addUTCTime (- week) <$> getCurrentTime cutoffTs <- addUTCTime (-week) <$> getCurrentTime
fileIds :: [Int64] <- fileIds :: [Int64] <-
map fromOnly map fromOnly
<$> DB.query <$> DB.query
@@ -132,7 +132,7 @@ getLiveSndFileTransfers db User {userId} = do
getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer] getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer]
getLiveRcvFileTransfers db user@User {userId} = do getLiveRcvFileTransfers db user@User {userId} = do
cutoffTs <- addUTCTime (- week) <$> getCurrentTime cutoffTs <- addUTCTime (-week) <$> getCurrentTime
fileIds :: [Int64] <- fileIds :: [Int64] <-
map fromOnly map fromOnly
<$> DB.query <$> DB.query
@@ -234,11 +234,12 @@ createSndGroupInlineFT db GroupMember {groupMemberId, localDisplayName = n} Conn
updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> ExceptT StoreError IO () updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> ExceptT StoreError IO ()
updateSndDirectFTDelivery _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName updateSndDirectFTDelivery _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName
updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = liftIO $ updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId =
DB.execute liftIO $
db DB.execute
"UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_inline IS NOT NULL" db
(msgDeliveryId, connId, fileId) "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.Connection -> GroupMember -> Connection -> FileTransferMeta -> Int64 -> IO ()
updateSndGroupFTDelivery db GroupMember {groupMemberId} Connection {connId} FileTransferMeta {fileId} msgDeliveryId = updateSndGroupFTDelivery db GroupMember {groupMemberId} Connection {connId} FileTransferMeta {fileId} msgDeliveryId =
@@ -724,7 +725,7 @@ removeFileCryptoArgs db fileId = do
getRcvFilesToReceive :: DB.Connection -> User -> IO [RcvFileTransfer] getRcvFilesToReceive :: DB.Connection -> User -> IO [RcvFileTransfer]
getRcvFilesToReceive db user@User {userId} = do getRcvFilesToReceive db user@User {userId} = do
cutoffTs <- addUTCTime (- (2 * nominalDay)) <$> getCurrentTime cutoffTs <- addUTCTime (-(2 * nominalDay)) <$> getCurrentTime
fileIds :: [Int64] <- fileIds :: [Int64] <-
map fromOnly map fromOnly
<$> DB.query <$> DB.query
@@ -768,20 +769,20 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation {
pure $ case map fromOnly ns of pure $ case map fromOnly ns of
[] []
| chunkNo == 1 -> | chunkNo == 1 ->
if chunkSize >= fileSize if chunkSize >= fileSize
then RcvChunkFinal then RcvChunkFinal
else RcvChunkOk else RcvChunkOk
| otherwise -> RcvChunkError | otherwise -> RcvChunkError
n : _ n : _
| chunkNo == n -> RcvChunkDuplicate | chunkNo == n -> RcvChunkDuplicate
| chunkNo == n + 1 -> | chunkNo == n + 1 ->
let prevSize = n * chunkSize let prevSize = n * chunkSize
in if prevSize >= fileSize in if prevSize >= fileSize
then RcvChunkError then RcvChunkError
else else
if prevSize + chunkSize >= fileSize if prevSize + chunkSize >= fileSize
then RcvChunkFinal then RcvChunkFinal
else RcvChunkOk else RcvChunkOk
| otherwise -> RcvChunkError | otherwise -> RcvChunkError
updatedRcvFileChunkStored :: DB.Connection -> RcvFileTransfer -> Integer -> IO () updatedRcvFileChunkStored :: DB.Connection -> RcvFileTransfer -> Integer -> IO ()
+140 -138
View File
@@ -2,14 +2,13 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Groups module Simplex.Chat.Store.Groups
@@ -122,7 +121,7 @@ import Crypto.Random (ChaChaDRG)
import Data.Either (rights) import Data.Either (rights)
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (partition, sortOn) 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.Ord (Down (..))
import Data.Text (Text) import Data.Text (Text)
import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.Clock (UTCTime (..), getCurrentTime)
@@ -446,39 +445,39 @@ createGroupInvitedViaLink
void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs supportedChatVRange void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs supportedChatVRange
liftIO $ setViaGroupLinkHash db groupId connId liftIO $ setViaGroupLinkHash db groupId connId
(,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId
where where
insertGroup_ currentTs = ExceptT $ do insertGroup_ currentTs = ExceptT $ do
let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile
withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do
liftIO $ do liftIO $ do
DB.execute DB.execute
db db
"INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" "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) (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs)
profileId <- insertedRowId db profileId <- insertedRowId db
DB.execute DB.execute
db 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 (?,?,?,?,?,?,?,?)" "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) (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs)
insertedRowId db insertedRowId db
insertHost_ currentTs groupId = ExceptT $ do insertHost_ currentTs groupId = ExceptT $ do
let fromMemberProfile = profileFromName fromMemberName let fromMemberProfile = profileFromName fromMemberName
withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do
(_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs
let MemberIdRole {memberId, memberRole} = fromMember let MemberIdRole {memberId, memberRole} = fromMember
liftIO $ do liftIO $ do
DB.execute DB.execute
db db
[sql| [sql|
INSERT INTO group_members INSERT INTO group_members
( group_id, member_id, member_role, member_category, member_status, invited_by, ( 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) user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|] |]
( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
) )
insertedRowId db insertedRowId db
setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO ()
setViaGroupLinkHash db groupId connId = setViaGroupLinkHash db groupId connId =
@@ -814,22 +813,22 @@ createAcceptedMember
insertMember_ (MemberId memId) createdAt insertMember_ (MemberId memId) createdAt
groupMemberId <- liftIO $ insertedRowId db groupMemberId <- liftIO $ insertedRowId db
pure (groupMemberId, MemberId memId) pure (groupMemberId, MemberId memId)
where where
JVersionRange (VersionRange minV maxV) = cReqChatVRange JVersionRange (VersionRange minV maxV) = cReqChatVRange
insertMember_ memberId createdAt = insertMember_ memberId createdAt =
DB.execute DB.execute
db db
[sql| [sql|
INSERT INTO group_members INSERT INTO group_members
( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, ( 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, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version) peer_chat_min_version, peer_chat_max_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|] |]
( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership)
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt)
:. (minV, maxV) :. (minV, maxV)
) )
createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO ()
createAcceptedMemberConnection createAcceptedMemberConnection
@@ -957,23 +956,24 @@ createNewMember_
:. (minV, maxV) :. (minV, maxV)
) )
groupMemberId <- insertedRowId db groupMemberId <- insertedRowId db
pure GroupMember { pure
groupMemberId, GroupMember
groupId, { groupMemberId,
memberId, groupId,
memberRole, memberId,
memberCategory, memberRole,
memberStatus, memberCategory,
memberSettings = defaultMemberSettings, memberStatus,
invitedBy, memberSettings = defaultMemberSettings,
invitedByGroupMemberId = memInvitedByGroupMemberId, invitedBy,
localDisplayName, invitedByGroupMemberId = memInvitedByGroupMemberId,
memberProfile = toLocalProfile memberContactProfileId memberProfile "", localDisplayName,
memberContactId, memberProfile = toLocalProfile memberContactProfileId memberProfile "",
memberContactProfileId, memberContactId,
activeConn, memberContactProfileId,
memberChatVRange = JVersionRange mcvr activeConn,
} memberChatVRange = JVersionRange mcvr
}
checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId)
checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} =
@@ -1104,41 +1104,41 @@ getForwardIntroducedMembers :: DB.Connection -> User -> GroupMember -> Bool -> I
getForwardIntroducedMembers db user invitee highlyAvailable = do getForwardIntroducedMembers db user invitee highlyAvailable = do
memberIds <- map fromOnly <$> query memberIds <- map fromOnly <$> query
filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds
where where
mId = groupMemberId' invitee mId = groupMemberId' invitee
query query
| highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected)
| otherwise = | otherwise =
DB.query DB.query
db db
(q <> " AND intro_chat_protocol_version >= ?") (q <> " AND intro_chat_protocol_version >= ?")
(mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange)
q = q =
[sql| [sql|
SELECT re_group_member_id SELECT re_group_member_id
FROM group_member_intros FROM group_member_intros
WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?)
|] |]
getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardInvitedMembers :: DB.Connection -> User -> GroupMember -> Bool -> IO [GroupMember]
getForwardInvitedMembers db user forwardMember highlyAvailable = do getForwardInvitedMembers db user forwardMember highlyAvailable = do
memberIds <- map fromOnly <$> query memberIds <- map fromOnly <$> query
filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds
where where
mId = groupMemberId' forwardMember mId = groupMemberId' forwardMember
query query
| highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected)
| otherwise = | otherwise =
DB.query DB.query
db db
(q <> " AND intro_chat_protocol_version >= ?") (q <> " AND intro_chat_protocol_version >= ?")
(mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange) (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, minVersion groupForwardVRange)
q = q =
[sql| [sql|
SELECT to_group_member_id SELECT to_group_member_id
FROM group_member_intros FROM group_member_intros
WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) 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.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 createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memChatVRange memberProfile) (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do
@@ -1263,15 +1263,15 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo 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} updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
| displayName == newName = liftIO $ do | displayName == newName = liftIO $ do
currentTs <- getCurrentTime
updateGroupProfile_ currentTs
pure (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
| otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateGroupProfile_ currentTs updateGroupProfile_ currentTs
updateGroup_ ldn currentTs pure (g :: GroupInfo) {groupProfile = p', fullGroupPreferences}
pure $ Right (g :: GroupInfo) {localDisplayName = ldn, 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 where
fullGroupPreferences = mergeGroupPreferences groupPreferences fullGroupPreferences = mergeGroupPreferences groupPreferences
updateGroupProfile_ currentTs = updateGroupProfile_ currentTs =
@@ -1317,31 +1317,33 @@ getGroupInfo db User {userId, userContactId} groupId =
getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo)
getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do
groupId_ <- maybeFirstRow fromOnly $ groupId_ <-
DB.query maybeFirstRow fromOnly $
db DB.query
[sql| db
SELECT group_id [sql|
FROM user_contact_links SELECT group_id
WHERE user_id = ? AND conn_req_contact IN (?,?) FROM user_contact_links
|] WHERE user_id = ? AND conn_req_contact IN (?,?)
(userId, cReqSchema1, cReqSchema2) |]
(userId, cReqSchema1, cReqSchema2)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_
getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo)
getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do
groupId_ <- maybeFirstRow fromOnly $ groupId_ <-
DB.query maybeFirstRow fromOnly $
db DB.query
[sql| db
SELECT g.group_id [sql|
FROM groups g SELECT g.group_id
JOIN group_members mu ON mu.group_id = g.group_id FROM groups g
WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) JOIN group_members mu ON mu.group_id = g.group_id
AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?)
LIMIT 1 AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?)
|] LIMIT 1
(userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) |]
(userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
@@ -1935,18 +1937,18 @@ createMemberContactConn_
updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember
updateMemberProfile db User {userId} m p' updateMemberProfile db User {userId} m p'
| displayName == newName = do | displayName == newName = do
liftIO $ updateContactProfile_ db userId profileId p' liftIO $ updateContactProfile_ db userId profileId p'
pure m {memberProfile = profile} pure m {memberProfile = profile}
| otherwise = | otherwise =
ExceptT . withLocalDisplayName db userId newName $ \ldn -> do ExceptT . withLocalDisplayName db userId newName $ \ldn -> do
currentTs <- getCurrentTime currentTs <- getCurrentTime
updateContactProfile_' db userId profileId p' currentTs updateContactProfile_' db userId profileId p' currentTs
DB.execute DB.execute
db db
"UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?"
(ldn, currentTs, userId, groupMemberId) (ldn, currentTs, userId, groupMemberId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId)
pure $ Right m {localDisplayName = ldn, memberProfile = profile} pure $ Right m {localDisplayName = ldn, memberProfile = profile}
where where
GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m
Profile {displayName = newName} = p' Profile {displayName = newName} = p'
+69 -69
View File
@@ -10,7 +10,6 @@
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Messages module Simplex.Chat.Store.Messages
@@ -199,40 +198,41 @@ createNewMessageAndRcvMsgDelivery db connOrGroupId newMessage sharedMsgId_ RcvMs
createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs
pure msg pure msg
createNewRcvMessage :: forall e. (MsgEncodingI e) => DB.Connection -> ConnOrGroupId -> NewMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage 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 db connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember =
case connOrGroupId of case connOrGroupId of
ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing
GroupId groupId -> case sharedMsgId_ of GroupId groupId -> case sharedMsgId_ of
Just sharedMsgId -> liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case Just sharedMsgId ->
Just (duplAuthorId, duplFwdMemberId) -> liftIO (duplicateGroupMsgMemberIds groupId sharedMsgId) >>= \case
throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId Just (duplAuthorId, duplFwdMemberId) ->
Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId throwError $ SEDuplicateGroupMessage groupId sharedMsgId duplAuthorId duplFwdMemberId
Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId
Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId Nothing -> liftIO $ insertRcvMsg Nothing $ Just groupId
where where
duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId)) duplicateGroupMsgMemberIds :: Int64 -> SharedMsgId -> IO (Maybe (Maybe GroupMemberId, Maybe GroupMemberId))
duplicateGroupMsgMemberIds groupId sharedMsgId = duplicateGroupMsgMemberIds groupId sharedMsgId =
maybeFirstRow id maybeFirstRow id $
$ DB.query 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 db
[sql| [sql|
SELECT author_group_member_id, forwarded_by_group_member_id INSERT INTO messages
FROM 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)
WHERE group_id = ? AND shared_msg_id = ? LIMIT 1 VALUES (?,?,?,?,?,?,?,?,?,?)
|] |]
(groupId, sharedMsgId) (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember)
insertRcvMsg connId_ groupId_ = do msgId <- insertedRowId db
currentTs <- getCurrentTime pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember}
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}
createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO () createSndMsgDeliveryEvent :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> ExceptT StoreError IO ()
createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do createSndMsgDeliveryEvent db connId agentMsgId sndMsgDeliveryStatus = do
@@ -528,12 +528,12 @@ getDirectChatPreviews_ db user@User {userId} = do
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id 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 connections c ON c.contact_id = ct.contact_id
LEFT JOIN ( LEFT JOIN (
SELECT contact_id, MAX(chat_item_id) AS MaxId SELECT contact_id, chat_item_id, MAX(created_at)
FROM chat_items FROM chat_items
GROUP BY contact_id GROUP BY contact_id
) MaxIds ON MaxIds.contact_id = ct.contact_id ) LastItems ON LastItems.contact_id = ct.contact_id
LEFT JOIN chat_items i ON i.contact_id = MaxIds.contact_id LEFT JOIN chat_items i ON i.contact_id = LastItems.contact_id
AND i.chat_item_id = MaxIds.MaxId AND i.chat_item_id = LastItems.chat_item_id
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN ( LEFT JOIN (
SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread
@@ -615,12 +615,12 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
JOIN group_members mu ON mu.group_id = g.group_id 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) JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
LEFT JOIN ( LEFT JOIN (
SELECT group_id, MAX(chat_item_id) AS MaxId SELECT group_id, chat_item_id, MAX(item_ts)
FROM chat_items FROM chat_items
GROUP BY group_id GROUP BY group_id
) MaxIds ON MaxIds.group_id = g.group_id ) LastItems ON LastItems.group_id = g.group_id
LEFT JOIN chat_items i ON i.group_id = MaxIds.group_id LEFT JOIN chat_items i ON i.group_id = LastItems.group_id
AND i.chat_item_id = MaxIds.MaxId AND i.chat_item_id = LastItems.chat_item_id
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
LEFT JOIN ( LEFT JOIN (
SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread
@@ -724,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 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 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 '%' || ? || '%' 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 ? LIMIT ?
|] |]
(userId, contactId, search, count) (userId, contactId, search, count)
@@ -754,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 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 '%' || ? || '%' WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id > ? AND i.chat_item_id > ?
ORDER BY i.chat_item_id ASC ORDER BY i.created_at ASC, i.chat_item_id ASC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, afterChatItemId, count) (userId, contactId, search, afterChatItemId, count)
@@ -784,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 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 '%' || ? || '%' WHERE i.user_id = ? AND i.contact_id = ? AND i.item_text LIKE '%' || ? || '%'
AND i.chat_item_id < ? AND i.chat_item_id < ?
ORDER BY i.chat_item_id DESC ORDER BY i.created_at DESC, i.chat_item_id DESC
LIMIT ? LIMIT ?
|] |]
(userId, contactId, search, beforeChatItemId, count) (userId, contactId, search, beforeChatItemId, count)
@@ -1802,22 +1802,22 @@ getDirectReactions db ct itemSharedMId sent =
setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO ()
setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs
| add = | add =
DB.execute DB.execute
db db
[sql| [sql|
INSERT INTO chat_item_reactions INSERT INTO chat_item_reactions
(contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts)
VALUES (?,?,?,?,?,?) VALUES (?,?,?,?,?,?)
|] |]
(contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs)
| otherwise = | otherwise =
DB.execute DB.execute
db db
[sql| [sql|
DELETE FROM chat_item_reactions DELETE FROM chat_item_reactions
WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ?
|] |]
(contactId' ct, itemSharedMId, sent, reaction) (contactId' ct, itemSharedMId, sent, reaction)
getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction]
getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent =
@@ -1834,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.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO ()
setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs
| add = | add =
DB.execute DB.execute
db db
[sql| [sql|
INSERT INTO chat_item_reactions 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) (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?)
|] |]
(groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs)
| otherwise = | otherwise =
DB.execute DB.execute
db db
[sql| [sql|
DELETE FROM chat_item_reactions 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 = ? 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) (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction)
getTimedItems :: DB.Connection -> User -> UTCTime -> IO [((ChatRef, ChatItemId), UTCTime)] getTimedItems :: DB.Connection -> User -> UTCTime -> IO [((ChatRef, ChatItemId), UTCTime)]
getTimedItems db User {userId} startTimedThreadCutoff = getTimedItems db User {userId} startTimedThreadCutoff =
+3 -1
View File
@@ -90,6 +90,7 @@ import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received
import Simplex.Chat.Migrations.M20231107_indexes import Simplex.Chat.Migrations.M20231107_indexes
import Simplex.Chat.Migrations.M20231113_group_forward import Simplex.Chat.Migrations.M20231113_group_forward
import Simplex.Chat.Migrations.M20231114_remote_control import Simplex.Chat.Migrations.M20231114_remote_control
import Simplex.Chat.Migrations.M20231126_remote_ctrl_address
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@@ -179,7 +180,8 @@ schemaMigrations =
("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received), ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received),
("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes), ("20231107_indexes", m20231107_indexes, Just down_m20231107_indexes),
("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward), ("20231113_group_forward", m20231113_group_forward, Just down_m20231113_group_forward),
("20231114_remote_control", m20231114_remote_control, Just down_m20231114_remote_control) ("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 -- | The list of migrations in ascending order by date
+26 -26
View File
@@ -8,7 +8,6 @@
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Profiles module Simplex.Chat.Store.Profiles
@@ -66,9 +65,9 @@ import Control.Monad.IO.Class
import qualified Data.Aeson.TH as J import qualified Data.Aeson.TH as J
import Data.Functor (($>)) import Data.Functor (($>))
import Data.Int (Int64) import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.List.NonEmpty (NonEmpty)
import Data.Text (Text) import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Time.Clock (UTCTime (..), getCurrentTime)
@@ -89,7 +88,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Parsers (defaultJSON)
import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode)
import Simplex.Messaging.Transport.Client (TransportHost) 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.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User
createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime
@@ -248,19 +247,19 @@ updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOv
updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User
updateUserProfile db user p' updateUserProfile db user p'
| displayName == newName = do | displayName == newName = do
liftIO $ updateContactProfile_ db userId profileId p' liftIO $ updateContactProfile_ db userId profileId p'
pure user {profile, fullPreferences} pure user {profile, fullPreferences}
| otherwise = | otherwise =
checkConstraint SEDuplicateName . liftIO $ do checkConstraint SEDuplicateName . liftIO $ do
currentTs <- getCurrentTime currentTs <- getCurrentTime
DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId)
DB.execute DB.execute
db db
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
(newName, newName, userId, currentTs, currentTs) (newName, newName, userId, currentTs, currentTs)
updateContactProfile_' db userId profileId p' currentTs updateContactProfile_' db userId profileId p' currentTs
updateContact_ db userId userContactId localDisplayName newName currentTs updateContact_ db userId userContactId localDisplayName newName currentTs
pure user {localDisplayName = newName, profile, fullPreferences} pure user {localDisplayName = newName, profile, fullPreferences}
where where
User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} = user User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} = user
Profile {displayName = newName, preferences} = p' Profile {displayName = newName, preferences} = p'
@@ -457,17 +456,18 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) =
getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact)
getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do getContactWithoutConnViaAddress db user@User {userId} (cReqSchema1, cReqSchema2) = do
ctId_ <- maybeFirstRow fromOnly $ ctId_ <-
DB.query maybeFirstRow fromOnly $
db DB.query
[sql| db
SELECT ct.contact_id [sql|
FROM contacts ct SELECT ct.contact_id
JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id FROM contacts ct
LEFT JOIN connections c ON c.contact_id = ct.contact_id JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id
WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL 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) |]
(userId, cReqSchema1, cReqSchema2)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_ maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) ctId_
updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink
+25 -13
View File
@@ -8,6 +8,8 @@ module Simplex.Chat.Store.Remote where
import Control.Monad.Except import Control.Monad.Except
import Data.Int (Int64) import Data.Int (Int64)
import Data.Text (Text) import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8, decodeASCII)
import Data.Word (Word16)
import Database.SQLite.Simple (Only (..)) import Database.SQLite.Simple (Only (..))
import qualified Database.SQLite.Simple as SQL import qualified Database.SQLite.Simple as SQL
import Database.SQLite.Simple.QQ (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 Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.RemoteControl.Types import Simplex.RemoteControl.Types
import UnliftIO import UnliftIO
insertRemoteHost :: DB.Connection -> Text -> FilePath -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost :: DB.Connection -> Text -> FilePath -> Maybe RCCtrlAddress -> Maybe Word16 -> RCHostPairing -> ExceptT StoreError IO RemoteHostId
insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do insertRemoteHost db hostDeviceName storePath rcAddr_ bindPort_ RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do
KnownHostPairing {hostFingerprint, hostDhPubKey} <- KnownHostPairing {hostFingerprint, hostDhPubKey} <-
maybe (throwError SERemoteHostUnknown) pure kh_ maybe (throwError SERemoteHostUnknown) pure kh_
checkConstraint SERemoteHostDuplicateCA . liftIO $ checkConstraint SERemoteHostDuplicateCA . liftIO $
@@ -28,12 +31,14 @@ insertRemoteHost db hostDeviceName storePath RCHostPairing {caKey, caCert, idPri
db db
[sql| [sql|
INSERT INTO remote_hosts 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 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 liftIO $ insertedRowId db
where
(bindAddr_, bindIface_) = rcCtrlAddressFields_ rcAddr_
getRemoteHosts :: DB.Connection -> IO [RemoteHost] getRemoteHosts :: DB.Connection -> IO [RemoteHost]
getRemoteHosts db = getRemoteHosts db =
@@ -52,27 +57,34 @@ getRemoteHostByFingerprint db fingerprint =
remoteHostQuery :: SQL.Query remoteHostQuery :: SQL.Query
remoteHostQuery = remoteHostQuery =
[sql| [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 FROM remote_hosts
|] |]
toRemoteHost :: (Int64, Text, FilePath, C.APrivateSignKey, C.SignedObject C.Certificate, C.PrivateKeyEd25519, C.KeyHash, C.PublicKeyX25519) -> RemoteHost 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) = toRemoteHost (remoteHostId, hostDeviceName, storePath, caKey, C.SignedObject caCert, idPrivKey, hostFingerprint, hostDhPubKey, ifaceName_, ifaceAddr_, bindPort_) =
RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing} RemoteHost {remoteHostId, hostDeviceName, storePath, hostPairing, bindAddress_, bindPort_}
where where
hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost} hostPairing = RCHostPairing {caKey, caCert, idPrivKey, knownHost = Just knownHost}
knownHost = KnownHostPairing {hostFingerprint, hostDhPubKey} 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.Connection -> RemoteHostId -> Text -> C.PublicKeyX25519 -> Maybe RCCtrlAddress -> Maybe Word16 -> IO ()
updateHostPairing db rhId hostDeviceName hostDhPubKey = updateHostPairing db rhId hostDeviceName hostDhPubKey rcAddr_ bindPort_ =
DB.execute DB.execute
db db
[sql| [sql|
UPDATE remote_hosts 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 = ? 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.Connection -> RemoteHostId -> IO ()
deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId) deleteRemoteHostRecord db remoteHostId = DB.execute db "DELETE FROM remote_hosts WHERE remote_host_id = ?" (Only remoteHostId)
+1 -1
View File
@@ -101,7 +101,7 @@ data StoreError
| SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId}
| SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId} | SEDuplicateGroupMessage {groupId :: Int64, sharedMsgId :: SharedMsgId, authorGroupMemberId :: Maybe GroupMemberId, forwardedByGroupMemberId :: Maybe GroupMemberId}
| SERemoteHostNotFound {remoteHostId :: RemoteHostId} | SERemoteHostNotFound {remoteHostId :: RemoteHostId}
| SERemoteHostUnknown -- ^ attempting to store KnownHost without a known fingerprint | SERemoteHostUnknown -- attempting to store KnownHost without a known fingerprint
| SERemoteHostDuplicateCA | SERemoteHostDuplicateCA
| SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId}
| SERemoteCtrlDuplicateCA | SERemoteCtrlDuplicateCA
+15 -15
View File
@@ -194,19 +194,19 @@ receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatSt
case lm_ of case lm_ of
Just LiveMessage {chatName} Just LiveMessage {chatName}
| live -> do | live -> do
writeTVar termState ts' {previousInput} writeTVar termState ts' {previousInput}
writeTBQueue inputQ $ "/live " <> chatNameStr chatName writeTBQueue inputQ $ "/live " <> chatNameStr chatName
| otherwise -> | otherwise ->
writeTVar termState ts' {inputPrompt = "> ", previousInput} writeTVar termState ts' {inputPrompt = "> ", previousInput}
where where
previousInput = chatNameStr chatName <> " " <> s previousInput = chatNameStr chatName <> " " <> s
_ _
| live -> when (isSend s) $ do | live -> when (isSend s) $ do
writeTVar termState ts' {previousInput = s} writeTVar termState ts' {previousInput = s}
writeTBQueue inputQ $ "/live " <> s writeTBQueue inputQ $ "/live " <> s
| otherwise -> do | otherwise -> do
writeTVar termState ts' {inputPrompt = "> ", previousInput = s} writeTVar termState ts' {inputPrompt = "> ", previousInput = s}
writeTBQueue inputQ s writeTBQueue inputQ s
pure $ (s,) <$> lm_ pure $ (s,) <$> lm_
where where
isSend s = length s > 1 && (head s == '@' || head s == '#') isSend s = length s > 1 && (head s == '@' || head s == '#')
@@ -343,9 +343,9 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr
charsWithContact cs charsWithContact cs
| live = cs | live = cs
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" =
chatPrefix <> cs chatPrefix <> cs
| (s == ">" || s == "\\" || s == "!") && cs == " " = | (s == ">" || s == "\\" || s == "!") && cs == " " =
cs <> chatPrefix cs <> chatPrefix
| otherwise = cs | otherwise = cs
insertChars = ts' . if p >= length s then append else insert insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s') append cs = let s' = s <> cs in (s', length s')
@@ -381,13 +381,13 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr
prevWordPos prevWordPos
| p == 0 || null s = p | p == 0 || null s = p
| otherwise = | otherwise =
let before = take p s let before = take p s
beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before beforeWord = dropWhileEnd (/= ' ') $ dropWhileEnd (== ' ') before
in max 0 $ p - length before + length beforeWord in max 0 $ p - length before + length beforeWord
nextWordPos nextWordPos
| p >= length s || null s = p | p >= length s || null s = p
| otherwise = | otherwise =
let after = drop p s let after = drop p s
afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after afterWord = dropWhile (/= ' ') $ dropWhile (== ' ') after
in min (length s) $ p + length after - length afterWord in min (length s) $ p + length after - length afterWord
ts' (s', p') = ts {inputString = s', inputPosition = p', autoComplete = acp {acTabPressed = False}} ts' (s', p') = ts {inputString = s', inputPosition = p', autoComplete = acp {acTabPressed = False}}
+8 -7
View File
@@ -24,7 +24,7 @@ import Simplex.Chat (execChatCommand, processChatCommand)
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Chat.Markdown import Simplex.Chat.Markdown
import Simplex.Chat.Messages 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.Options
import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Protocol (MsgContent (..), msgContentText)
import Simplex.Chat.Remote.Types (RHKey (..), RemoteHostId, RemoteHostInfo (..), RemoteHostSession (..)) import Simplex.Chat.Remote.Types (RHKey (..), RemoteHostId, RemoteHostInfo (..), RemoteHostSession (..))
@@ -167,9 +167,10 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d
void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc
_ -> pure () _ -> pure ()
logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s
getRemoteUser rhId = runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case getRemoteUser rhId =
CRActiveUser {user} -> updateRemoteUser ct user rhId runReaderT (execChatCommand (Just rhId) "/user") cc >>= \case
cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr CRActiveUser {user} -> updateRemoteUser ct user rhId
cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr
removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct)
responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO ()
@@ -326,9 +327,9 @@ updateInput ChatTerminal {termSize = Size {height, width}, termState, nextMessag
clearLines from till clearLines from till
| from >= till = return () | from >= till = return ()
| otherwise = do | otherwise = do
setCursorPosition $ Position {row = from, col = 0} setCursorPosition $ Position {row = from, col = 0}
eraseInLine EraseForward eraseInLine EraseForward
clearLines (from + 1) till clearLines (from + 1) till
inputHeight :: TerminalState -> Int inputHeight :: TerminalState -> Int
inputHeight ts = length (autoCompletePrefix ts <> inputPrompt ts <> inputString ts) `div` width + 1 inputHeight ts = length (autoCompletePrefix ts <> inputPrompt ts <> inputString ts) `div` width + 1
autoCompletePrefix :: TerminalState -> String autoCompletePrefix :: TerminalState -> String
+3 -4
View File
@@ -17,7 +17,6 @@
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE TypeFamilyDependencies #-}
{-# LANGUAGE UndecidableInstances #-} {-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use newtype instead of data" #-} {-# HLINT ignore "Use newtype instead of data" #-}
@@ -40,7 +39,7 @@ import qualified Data.Text as T
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import Data.Typeable (Typeable) import Data.Typeable (Typeable)
import Database.SQLite.Simple (ResultError (..), SQLData (..)) 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.Internal (Field (..))
import Database.SQLite.Simple.Ok import Database.SQLite.Simple.Ok
import Database.SQLite.Simple.ToField (ToField (..)) import Database.SQLite.Simple.ToField (ToField (..))
@@ -50,7 +49,7 @@ import Simplex.FileTransfer.Description (FileDigest)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String 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.Protocol (ProtoServerWithAuth, ProtocolTypeI)
import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Util ((<$?>))
import Simplex.Messaging.Version import Simplex.Messaging.Version
@@ -498,7 +497,7 @@ data LocalProfile = LocalProfile
deriving (Eq, Show) deriving (Eq, Show)
localProfileId :: LocalProfile -> ProfileId localProfileId :: LocalProfile -> ProfileId
localProfileId LocalProfile{profileId} = profileId localProfileId LocalProfile {profileId} = profileId
toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile
toLocalProfile profileId Profile {displayName, fullName, image, contactLink, preferences} localAlias = toLocalProfile profileId Profile {displayName, fullName, image, contactLink, preferences} localAlias =
-1
View File
@@ -14,7 +14,6 @@
{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE TypeFamilyDependencies #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
+1 -1
View File
@@ -2,7 +2,7 @@
module Simplex.Chat.Types.Util where module Simplex.Chat.Types.Util where
import Data.Aeson (ToJSON, FromJSON) import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J import qualified Data.Aeson as J
import Data.ByteString (ByteString) import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.ByteString.Lazy.Char8 as LB
+101 -93
View File
@@ -14,8 +14,8 @@ module Simplex.Chat.View where
import qualified Data.Aeson as J import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.TH as JQ
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Char8 (ByteString) import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isSpace, toUpper) import Data.Char (isSpace, toUpper)
import Data.Function (on) import Data.Function (on)
@@ -44,8 +44,8 @@ import Simplex.Chat.Markdown
import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange)
import Simplex.Chat.Remote.Types import Simplex.Chat.Remote.Types
import Simplex.Chat.Remote.AppVersion (pattern AppVersionRange, AppVersion (..))
import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled import Simplex.Chat.Styled
import Simplex.Chat.Types import Simplex.Chat.Types
@@ -65,6 +65,7 @@ import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Transport.Client (TransportHost (..))
import Simplex.Messaging.Util (bshow, tshow) import Simplex.Messaging.Util (bshow, tshow)
import Simplex.Messaging.Version hiding (version) import Simplex.Messaging.Version hiding (version)
import Simplex.RemoteControl.Types (RCCtrlAddress (..))
import System.Console.ANSI.Types import System.Console.ANSI.Types
type CurrentTime = UTCTime type CurrentTime = UTCTime
@@ -163,8 +164,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq
CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c
CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan
CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"]
CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRSentInvitationToContact u _c 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"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"]
CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"]
@@ -274,7 +275,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRCallInvitations _ -> [] CRCallInvitations _ -> []
CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"]
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] 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"] CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)]
@@ -286,13 +286,13 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
rhi_ rhi_
] ]
CRRemoteHostList hs -> viewRemoteHosts hs 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_, [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_,
"Remote session invitation:", "Remote session invitation:",
plain invitation plain invitation
] ]
where where
started = " started on port " <> ctrlPort started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort
CRRemoteHostSessionCode {remoteHost_, sessionCode} -> CRRemoteHostSessionCode {remoteHost_, sessionCode} ->
[ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_, [ maybe "new remote host connecting" (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> sShow rhId <> " connecting") remoteHost_,
"Compare session code with host:", "Compare session code with host:",
@@ -308,10 +308,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
<> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_ <> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_
CRRemoteCtrlList cs -> viewRemoteCtrls cs CRRemoteCtrlList cs -> viewRemoteCtrls cs
CRRemoteCtrlFound {remoteCtrl = RemoteCtrlInfo {remoteCtrlId, ctrlDeviceName}, ctrlAppInfo_, appVersion, compatible} -> 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_ <> 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 where
deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", " deviceName = if T.null ctrlDeviceName then "" else plain ctrlDeviceName <> ", "
CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} -> CRRemoteCtrlConnecting {remoteCtrl_, ctrlAppInfo, appVersion} ->
@@ -511,42 +511,43 @@ viewChats ts tz = concatMap chatPreview . reverse
viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] 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 = viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, content, quotedItem, file} doShow ts tz =
withGroupMsgForwarded . withItemDeleted <$> (case chat of withGroupMsgForwarded . withItemDeleted <$> viewCI
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
_ -> [])
where 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 withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of
Nothing -> item Nothing -> item
Just t -> item <> styled (colored Red) (" [" <> t <> "]") Just t -> item <> styled (colored Red) (" [" <> t <> "]")
@@ -667,15 +668,15 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem
| timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView] | timed = [plain ("timed message deleted: " <> T.unpack (ciContentToText deletedContent)) | testView]
| byUser = [plain $ "message " <> T.unpack (fromMaybe "deleted" deletedText_)] -- deletedText_ Nothing should be impossible here | byUser = [plain $ "message " <> T.unpack (fromMaybe "deleted" deletedText_)] -- deletedText_ Nothing should be impossible here
| otherwise = case chat of | otherwise = case chat of
DirectChat c -> case (chatDir, deletedContent) of DirectChat c -> case (chatDir, deletedContent) of
(CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta (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 _ -> 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 where
deletedText_ :: Maybe Text deletedText_ :: Maybe Text
deletedText_ = case toItem of deletedText_ = case toItem of
@@ -788,7 +789,7 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
viewContactsList :: [Contact] -> [StyledString] viewContactsList :: [Contact] -> [StyledString]
viewContactsList = viewContactsList =
let getLDN :: Contact -> ContactName let getLDN :: Contact -> ContactName
getLDN Contact{localDisplayName} = localDisplayName getLDN Contact {localDisplayName} = localDisplayName
ldn = T.toLower . getLDN ldn = T.toLower . getLDN
in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn
where where
@@ -823,8 +824,8 @@ simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simpl
autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString]
autoAcceptStatus_ = \case autoAcceptStatus_ = \case
Just AutoAccept {acceptIncognito, autoReply} -> Just AutoAccept {acceptIncognito, autoReply} ->
("auto_accept on" <> if acceptIncognito then ", incognito" else "") : ("auto_accept on" <> if acceptIncognito then ", incognito" else "")
maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply : maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply
_ -> ["auto_accept off"] _ -> ["auto_accept off"]
groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString] groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString]
@@ -907,10 +908,10 @@ viewJoinedGroupMember g m =
viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString]
viewReceivedGroupInvitation g c role = viewReceivedGroupInvitation g c role =
ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) : ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role)
case incognitoMembershipProfile g of : case incognitoMembershipProfile g of
Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)]
Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"]
groupPreserved :: GroupInfo -> [StyledString] groupPreserved :: GroupInfo -> [StyledString]
groupPreserved g = ["use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group"] groupPreserved g = ["use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group"]
@@ -996,13 +997,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs
GSMemRemoved -> delete "you are removed" GSMemRemoved -> delete "you are removed"
GSMemLeft -> delete "you left" GSMemLeft -> delete "you left"
GSMemGroupDeleted -> delete "group deleted" GSMemGroupDeleted -> delete "group deleted"
_ -> " (" <> memberCount <> _ -> " (" <> memberCount <> viewNtf <> ")"
case enableNtfs of
MFAll -> ")"
MFNone -> ", muted, " <> unmute
MFMentions -> ", mentions only, " <> unmute
where 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) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")"
memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s"
@@ -1028,9 +1029,9 @@ viewContactsMerged c1 c2 ct' =
viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString]
viewContactAndMemberAssociated ct g m ct' = viewContactAndMemberAssociated ct g m ct' =
[ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m,
"use " <> ttyToContact' ct' <> highlight' "<message>" <> " to send messages" "use " <> ttyToContact' ct' <> highlight' "<message>" <> " to send messages"
] ]
viewUserProfile :: Profile -> [StyledString] viewUserProfile :: Profile -> [StyledString]
viewUserProfile Profile {displayName, fullName} = viewUserProfile Profile {displayName, fullName} =
@@ -1396,14 +1397,14 @@ viewContactUpdated
Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName', contactLink = contactLink'}} Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName', contactLink = contactLink'}}
| n == n' && fullName == fullName' && contactLink == contactLink' = [] | n == n' && fullName == fullName' && contactLink == contactLink' = []
| n == n' && fullName == fullName' = | n == n' && fullName == fullName' =
if isNothing contactLink' if isNothing contactLink'
then [ttyContact n <> " removed contact address"] then [ttyContact n <> " removed contact address"]
else [ttyContact n <> " set new contact address, use " <> highlight ("/info " <> n) <> " to view"] else [ttyContact n <> " set new contact address, use " <> highlight ("/info " <> n) <> " to view"]
| n == n' = ["contact " <> ttyContact n <> fullNameUpdate] | n == n' = ["contact " <> ttyContact n <> fullNameUpdate]
| otherwise = | otherwise =
[ "contact " <> ttyContact n <> " changed to " <> ttyFullName n' fullName', [ "contact " <> ttyContact n <> " changed to " <> ttyFullName n' fullName',
"use " <> ttyToContact n' <> highlight' "<message>" <> " to send messages" "use " <> ttyToContact n' <> highlight' "<message>" <> " to send messages"
] ]
where where
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
@@ -1428,11 +1429,11 @@ receivedWithTime_ ts tz from quote CIMeta {itemId, itemTs, itemEdited, itemDelet
live live
| itemEdited || isJust itemDeleted = "" | itemEdited || isJust itemDeleted = ""
| otherwise = case itemLive of | otherwise = case itemLive of
Just True Just True
| updated -> ttyFrom "[LIVE] " | updated -> ttyFrom "[LIVE] "
| otherwise -> ttyFrom "[LIVE started]" <> " use " <> highlight' ("/show [on/off/" <> show itemId <> "] ") | otherwise -> ttyFrom "[LIVE started]" <> " use " <> highlight' ("/show [on/off/" <> show itemId <> "] ")
Just False -> ttyFrom "[LIVE ended] " Just False -> ttyFrom "[LIVE ended] "
_ -> "" _ -> ""
ttyMsgTime :: CurrentTime -> TimeZone -> UTCTime -> StyledString ttyMsgTime :: CurrentTime -> TimeZone -> UTCTime -> StyledString
ttyMsgTime now tz time = ttyMsgTime now tz time =
@@ -1458,9 +1459,9 @@ viewSentMessage to quote mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive
live live
| itemEdited || isJust itemDeleted = "" | itemEdited || isJust itemDeleted = ""
| otherwise = case itemLive of | otherwise = case itemLive of
Just True -> ttyTo "[LIVE started] " Just True -> ttyTo "[LIVE started] "
Just False -> ttyTo "[LIVE] " Just False -> ttyTo "[LIVE] "
_ -> "" _ -> ""
viewSentBroadcast :: MsgContent -> Int -> Int -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] 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) viewSentBroadcast mc s f ts tz time = prependFirst (highlight' "/feed" <> " (" <> sShow s <> failures <> ") " <> ttyMsgTime ts tz time <> " ") (ttyMsgContent mc)
@@ -1551,11 +1552,12 @@ receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIF
cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"] cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"]
cfArgsStr _ = [] cfArgsStr _ = []
getRemoteFileStr = case hu of getRemoteFileStr = case hu of
(Just rhId, Just User {userId}) | status == "completed" -> (Just rhId, Just User {userId})
[ "File received to connected remote host " <> sShow rhId, | status == "completed" ->
"To download to this device use:", [ "File received to connected remote host " <> sShow rhId,
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f})) "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 receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
@@ -1591,7 +1593,7 @@ viewFileTransferStatus (FTSnd FileTransferMeta {cancelled} fts@(ft : _), chunksN
[recipientsStatus] -> ["sending " <> sndFile ft <> " " <> recipientsStatus] [recipientsStatus] -> ["sending " <> sndFile ft <> " " <> recipientsStatus]
recipientsStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) recipientsStatuses recipientsStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) recipientsStatuses
fs :: SndFileTransfer -> FileStatus fs :: SndFileTransfer -> FileStatus
fs SndFileTransfer{fileStatus} = fileStatus fs SndFileTransfer {fileStatus} = fileStatus
recipientsTransferStatus [] = [] recipientsTransferStatus [] = []
recipientsTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listRecipients ts] recipientsTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listRecipients ts]
where where
@@ -1711,8 +1713,13 @@ viewRemoteHosts = \case
[] -> ["No remote hosts"] [] -> ["No remote hosts"]
hs -> "Remote hosts: " : map viewRemoteHostInfo hs hs -> "Remote hosts: " : map viewRemoteHostInfo hs
where where
viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState} = viewRemoteHostInfo RemoteHostInfo {remoteHostId, hostDeviceName, sessionState, bindAddress_, bindPort_} =
plain $ tshow remoteHostId <> ". " <> hostDeviceName <> maybe "" viewSessionState sessionState 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 viewSessionState = \case
RHSStarting -> " (starting)" RHSStarting -> " (starting)"
RHSConnecting _ -> " (connecting)" RHSConnecting _ -> " (connecting)"
@@ -1763,9 +1770,10 @@ viewChatError logLevel testView = \case
CEEmptyUserPassword _ -> ["user password is required"] CEEmptyUserPassword _ -> ["user password is required"]
CEUserAlreadyHidden _ -> ["user is already hidden"] CEUserAlreadyHidden _ -> ["user is already hidden"]
CEUserNotHidden _ -> ["user is not hidden"] CEUserNotHidden _ -> ["user is not hidden"]
CEInvalidDisplayName {displayName, validName} -> map plain $ CEInvalidDisplayName {displayName, validName} ->
["invalid display name: " <> viewName displayName] map plain $
<> ["you could use this one: " <> viewName validName | not (T.null validName)] ["invalid display name: " <> viewName displayName]
<> ["you could use this one: " <> viewName validName | not (T.null validName)]
CEChatNotStarted -> ["error: chat not started"] CEChatNotStarted -> ["error: chat not started"]
CEChatNotStopped -> ["error: chat not stopped"] CEChatNotStopped -> ["error: chat not stopped"]
CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
+1 -3
View File
@@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq # - ../simplexmq
- github: simplex-chat/simplexmq - github: simplex-chat/simplexmq
commit: 757b7eec81341d8560a326deab303bb6fb6a26a3 commit: 90a8fc91d35c578c3b52ad296a6f1df715da2278
- github: kazu-yamamoto/http2 - github: kazu-yamamoto/http2
commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
# - ../direct-sqlcipher # - ../direct-sqlcipher
@@ -65,8 +65,6 @@ extra-deps:
commit: f708b00009b54890172068f168bf98508ffcd495 commit: f708b00009b54890172068f168bf98508ffcd495
- github: simplex-chat/android-support - github: simplex-chat/android-support
commit: 9aa09f148089d6752ce563b14c2df1895718d806 commit: 9aa09f148089d6752ce563b14c2df1895718d806
- github: simplex-chat/network-transport
commit: 0013798272a683e35ca38d2fdaf480942311fba8
# #
# extra-deps: [] # extra-deps: []
+1 -2
View File
@@ -5,7 +5,6 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-} {-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ChatClient where module ChatClient where
@@ -276,7 +275,7 @@ getTermLine cc =
Just s -> do Just s -> do
-- remove condition to always echo virtual terminal -- remove condition to always echo virtual terminal
when (printOutput cc) $ do when (printOutput cc) $ do
-- when True $ do -- when True $ do
name <- userName cc name <- userName cc
putStrLn $ name <> ": " <> s putStrLn $ name <> ": " <> s
pure s pure s
+20 -19
View File
@@ -259,7 +259,6 @@ testPlanInvitationLinkOk =
bob ##> ("/_connect plan 1 " <> inv) bob ##> ("/_connect plan 1 " <> inv)
bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection
alice <##> bob alice <##> bob
testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO ()
@@ -283,7 +282,6 @@ testPlanInvitationLinkOwn tmp =
alice ##> ("/_connect plan 1 " <> inv) alice ##> ("/_connect plan 1 " <> inv)
alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection
alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)]
alice `send` "@alice_2 hi" alice `send` "@alice_2 hi"
alice alice
@@ -1213,31 +1211,34 @@ testMuteGroup =
cath `send` "> #team (hello) hello too!" cath `send` "> #team (hello) hello too!"
cath <# "#team > bob hello" cath <# "#team > bob hello"
cath <## " hello too!" cath <## " hello too!"
concurrently_ concurrentlyN_
(bob </) [ (bob </),
( do alice <# "#team cath> > bob hello" do
alice <## " hello too!" alice <# "#team cath> > bob hello"
) alice <## " hello too!"
]
bob ##> "/unmute mentions #team" bob ##> "/unmute mentions #team"
bob <## "ok" bob <## "ok"
alice `send` "> #team @bob (hello) hey bob!" alice `send` "> #team @bob (hello) hey bob!"
alice <# "#team > bob hello" alice <# "#team > bob hello"
alice <## " hey bob!" alice <## " hey bob!"
concurrently_ concurrentlyN_
( do bob <# "#team alice> > bob hello" [ do
bob <## " hey bob!" bob <# "#team alice> > bob hello"
) bob <## " hey bob!",
( do cath <# "#team alice> > bob hello" do
cath <## " hey bob!" cath <# "#team alice> > bob hello"
) cath <## " hey bob!"
]
alice `send` "> #team @cath (hello) hey cath!" alice `send` "> #team @cath (hello) hey cath!"
alice <# "#team > cath hello too!" alice <# "#team > cath hello too!"
alice <## " hey cath!" alice <## " hey cath!"
concurrently_ concurrentlyN_
(bob </) [ (bob </),
( do cath <# "#team alice> > cath hello too!" do
cath <## " hey cath!" cath <# "#team alice> > cath hello too!"
) cath <## " hey cath!"
]
bob ##> "/gs" bob ##> "/gs"
bob <## "#team (3 members, mentions only, you can /unmute #team)" bob <## "#team (3 members, mentions only, you can /unmute #team)"
bob ##> "/unmute #team" bob ##> "/unmute #team"
-1
View File
@@ -1,7 +1,6 @@
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-} {-# LANGUAGE PostfixOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module ChatTests.Files where module ChatTests.Files where
+9 -14
View File
@@ -7,7 +7,7 @@ import ChatClient
import ChatTests.Utils import ChatTests.Utils
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_) import Control.Concurrent.Async (concurrently_)
import Control.Monad (when, void) import Control.Monad (void, when)
import qualified Data.ByteString as B import qualified Data.ByteString as B
import Data.List (isInfixOf) import Data.List (isInfixOf)
import qualified Data.Text as T import qualified Data.Text as T
@@ -122,7 +122,8 @@ chatGroupTests = do
-- because host uses current code and sends version in MemberInfo -- because host uses current code and sends version in MemberInfo
testNoDirect vrMem2 vrMem3 noConns = testNoDirect vrMem2 vrMem3 noConns =
it it
( "host " <> vRangeStr supportedChatVRange ( "host "
<> vRangeStr supportedChatVRange
<> (", 2nd mem " <> vRangeStr vrMem2) <> (", 2nd mem " <> vRangeStr vrMem2)
<> (", 3rd mem " <> vRangeStr vrMem3) <> (", 3rd mem " <> vRangeStr vrMem3)
<> (if noConns then " : 2 <!!> 3" else " : 2 <##> 3") <> (if noConns then " : 2 <!!> 3" else " : 2 <##> 3")
@@ -3859,11 +3860,9 @@ testMemberContactProfileUpdate =
bob #> "#team hello too" bob #> "#team hello too"
alice <# "#team rob> hello too" alice <# "#team rob> hello too"
cath <# "#team bob> hello too" -- not updated profile cath <# "#team bob> hello too" -- not updated profile
cath #> "#team hello there" cath #> "#team hello there"
alice <# "#team kate> hello there" alice <# "#team kate> hello there"
bob <# "#team cath> hello there" -- not updated profile bob <# "#team cath> hello there" -- not updated profile
bob `send` "@cath hi" bob `send` "@cath hi"
bob bob
<### [ "member #team cath does not have direct connection, creating", <### [ "member #team cath does not have direct connection, creating",
@@ -3903,7 +3902,6 @@ testMemberContactProfileUpdate =
bob #> "#team hello too" bob #> "#team hello too"
alice <# "#team rob> hello too" alice <# "#team rob> hello too"
cath <# "#team rob> hello too" -- updated profile cath <# "#team rob> hello too" -- updated profile
cath #> "#team hello there" cath #> "#team hello there"
alice <# "#team kate> hello there" alice <# "#team kate> hello there"
bob <# "#team kate> hello there" -- updated profile bob <# "#team kate> hello there" -- updated profile
@@ -3911,7 +3909,7 @@ testMemberContactProfileUpdate =
testGroupMsgForward :: HasCallStack => FilePath -> IO () testGroupMsgForward :: HasCallStack => FilePath -> IO ()
testGroupMsgForward = testGroupMsgForward =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
bob #> "#team hi there" bob #> "#team hi there"
@@ -3941,7 +3939,6 @@ setupGroupForwarding3 gName alice bob cath = do
createGroup3 gName alice bob cath createGroup3 gName alice bob cath
threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected
void $ withCCTransaction bob $ \db -> void $ withCCTransaction bob $ \db ->
DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3" DB.execute_ db "UPDATE connections SET conn_status='deleted' WHERE group_member_id = 3"
void $ withCCTransaction cath $ \db -> void $ withCCTransaction cath $ \db ->
@@ -3956,7 +3953,6 @@ testGroupMsgForwardDeduplicate =
createGroup3 "team" alice bob cath createGroup3 "team" alice bob cath
threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected
void $ withCCTransaction alice $ \db -> void $ withCCTransaction alice $ \db ->
DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'"
@@ -3990,7 +3986,7 @@ testGroupMsgForwardDeduplicate =
testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO () testGroupMsgForwardEdit :: HasCallStack => FilePath -> IO ()
testGroupMsgForwardEdit = testGroupMsgForwardEdit =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
bob #> "#team hi there" bob #> "#team hi there"
@@ -4001,7 +3997,6 @@ testGroupMsgForwardEdit =
bob <# "#team [edited] hello there" bob <# "#team [edited] hello there"
alice <# "#team bob> [edited] hello there" alice <# "#team bob> [edited] hello there"
cath <# "#team bob> [edited] hello there" -- TODO show as forwarded cath <# "#team bob> [edited] hello there" -- TODO show as forwarded
alice ##> "/tail #team 1" alice ##> "/tail #team 1"
alice <# "#team bob> hello there" alice <# "#team bob> hello there"
@@ -4014,7 +4009,7 @@ testGroupMsgForwardEdit =
testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO () testGroupMsgForwardReaction :: HasCallStack => FilePath -> IO ()
testGroupMsgForwardReaction = testGroupMsgForwardReaction =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
bob #> "#team hi there" bob #> "#team hi there"
@@ -4031,7 +4026,7 @@ testGroupMsgForwardReaction =
testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO () testGroupMsgForwardDeletion :: HasCallStack => FilePath -> IO ()
testGroupMsgForwardDeletion = testGroupMsgForwardDeletion =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
bob #> "#team hi there" bob #> "#team hi there"
@@ -4073,7 +4068,7 @@ testGroupMsgForwardFile =
testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO () testGroupMsgForwardChangeRole :: HasCallStack => FilePath -> IO ()
testGroupMsgForwardChangeRole = testGroupMsgForwardChangeRole =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do \alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
cath ##> "/mr #team bob member" cath ##> "/mr #team bob member"
@@ -4084,7 +4079,7 @@ testGroupMsgForwardChangeRole =
testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO () testGroupMsgForwardNewMember :: HasCallStack => FilePath -> IO ()
testGroupMsgForwardNewMember = testGroupMsgForwardNewMember =
testChat4 aliceProfile bobProfile cathProfile danProfile $ testChat4 aliceProfile bobProfile cathProfile danProfile $
\alice bob cath dan -> do \alice bob cath dan -> do
setupGroupForwarding3 "team" alice bob cath setupGroupForwarding3 "team" alice bob cath
connectUsers cath dan connectUsers cath dan
+4 -4
View File
@@ -7,16 +7,16 @@ import ChatClient
import ChatTests.Utils import ChatTests.Utils
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_) import Control.Concurrent.Async (concurrently_)
import Control.Monad
import Control.Monad.Except import Control.Monad.Except
import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Store.Shared (createContact)
import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..)) import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..))
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import System.Directory (copyFile, createDirectoryIfMissing) import System.Directory (copyFile, createDirectoryIfMissing)
import Test.Hspec import Test.Hspec
import Simplex.Chat.Store.Shared (createContact)
import Control.Monad
import Simplex.Messaging.Encoding.String (StrEncoding(..))
chatProfileTests :: SpecWith FilePath chatProfileTests :: SpecWith FilePath
chatProfileTests = do chatProfileTests = do
@@ -633,7 +633,7 @@ testPlanAddressOwn tmp =
alice <## "alice_1 (Alice) wants to connect to you!" alice <## "alice_1 (Alice) wants to connect to you!"
alice <## "to accept: /ac alice_1" alice <## "to accept: /ac alice_1"
alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" 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 ##> "/ac alice_1"
alice <## "alice_1 (Alice): accepting contact request..." alice <## "alice_1 (Alice): accepting contact request..."
alice alice
+3 -3
View File
@@ -310,7 +310,7 @@ getInAnyOrder f cc ls = do
Predicate p -> p l Predicate p -> p l
filterFirst :: (a -> Bool) -> [a] -> [a] filterFirst :: (a -> Bool) -> [a] -> [a]
filterFirst _ [] = [] filterFirst _ [] = []
filterFirst p (x:xs) filterFirst p (x : xs)
| p x = xs | p x = xs
| otherwise = x : filterFirst p xs | otherwise = x : filterFirst p xs
@@ -593,7 +593,7 @@ vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxV
linkAnotherSchema :: String -> String linkAnotherSchema :: String -> String
linkAnotherSchema link linkAnotherSchema link
| "https://simplex.chat/" `isPrefixOf` 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 = | "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:/" | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/"
+36 -4
View File
@@ -38,6 +38,7 @@ remoteTests = describe "Remote" $ do
it "connects with stored pairing" remoteHandshakeStoredTest it "connects with stored pairing" remoteHandshakeStoredTest
it "connects with multicast discovery" remoteHandshakeDiscoverTest it "connects with multicast discovery" remoteHandshakeDiscoverTest
it "refuses invalid client cert" remoteHandshakeRejectTest it "refuses invalid client cert" remoteHandshakeRejectTest
it "connects with stored server bindings" storedBindingsTest
it "sends messages" remoteMessageTest it "sends messages" remoteMessageTest
describe "remote files" $ do describe "remote files" $ do
it "store/get/send/receive files" remoteStoreFileTest it "store/get/send/receive files" remoteStoreFileTest
@@ -117,7 +118,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil
mobileBob ##> "/set device name MobileBob" mobileBob ##> "/set device name MobileBob"
mobileBob <## "ok" mobileBob <## "ok"
desktop ##> "/start remote host 1" desktop ##> "/start remote host 1"
desktop <##. "remote host 1 started on port " desktop <##. "remote host 1 started on "
desktop <## "Remote session invitation:" desktop <## "Remote session invitation:"
inv <- getTermLine desktop inv <- getTermLine desktop
mobileBob ##> ("/connect remote ctrl " <> inv) mobileBob ##> ("/connect remote ctrl " <> inv)
@@ -138,6 +139,37 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil
desktop <## "remote host 1 connected" desktop <## "remote host 1 connected"
stopMobile mobile desktop 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) [lo 127.0.0.1:52230]"
stopDesktop mobile desktop
desktop ##> "/list remote hosts"
desktop <## "Remote hosts:"
desktop <## "1. Mobile [lo 127.0.0.1:52230]"
-- TODO: more parser tests
remoteMessageTest :: HasCallStack => FilePath -> IO () remoteMessageTest :: HasCallStack => FilePath -> IO ()
remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do
startRemote mobile desktop startRemote mobile desktop
@@ -475,7 +507,7 @@ startRemote mobile desktop = do
mobile ##> "/set device name Mobile" mobile ##> "/set device name Mobile"
mobile <## "ok" mobile <## "ok"
desktop ##> "/start remote host new" desktop ##> "/start remote host new"
desktop <##. "new remote host started on port " desktop <##. "new remote host started on "
desktop <## "Remote session invitation:" desktop <## "Remote session invitation:"
inv <- getTermLine desktop inv <- getTermLine desktop
mobile ##> ("/connect remote ctrl " <> inv) mobile ##> ("/connect remote ctrl " <> inv)
@@ -490,7 +522,7 @@ startRemote mobile desktop = do
startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored :: TestCC -> TestCC -> IO ()
startRemoteStored mobile desktop = do startRemoteStored mobile desktop = do
desktop ##> "/start remote host 1" desktop ##> "/start remote host 1"
desktop <##. "remote host 1 started on port " desktop <##. "remote host 1 started on "
desktop <## "Remote session invitation:" desktop <## "Remote session invitation:"
inv <- getTermLine desktop inv <- getTermLine desktop
mobile ##> ("/connect remote ctrl " <> inv) mobile ##> ("/connect remote ctrl " <> inv)
@@ -504,7 +536,7 @@ startRemoteStored mobile desktop = do
startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover :: TestCC -> TestCC -> IO ()
startRemoteDiscover mobile desktop = do startRemoteDiscover mobile desktop = do
desktop ##> "/start remote host 1 multicast=on" desktop ##> "/start remote host 1 multicast=on"
desktop <##. "remote host 1 started on port " desktop <##. "remote host 1 started on "
desktop <## "Remote session invitation:" desktop <## "Remote session invitation:"
_inv <- getTermLine desktop -- will use multicast instead _inv <- getTermLine desktop -- will use multicast instead
mobile ##> "/find remote ctrl" mobile ##> "/find remote ctrl"
+1 -1
View File
@@ -13,8 +13,8 @@ import RemoteTests
import SchemaDump import SchemaDump
import Test.Hspec import Test.Hspec
import UnliftIO.Temporary (withTempDirectory) import UnliftIO.Temporary (withTempDirectory)
import ViewTests
import ValidNames import ValidNames
import ViewTests
import WebRTCTests import WebRTCTests
main :: IO () main :: IO ()