Compare commits

..

3 Commits

Author SHA1 Message Date
Jesse Horne
e3fefbea1a removed qoutes from all translations 2023-11-27 09:26:15 -05:00
Jesse Horne
1ff3f6f0b2 removed the str replace and simply removed qoutes altogether 2023-11-25 16:09:53 -05:00
Jesse Horne
749d196861 hard replace for the forward slash since I don't want to modify anything related to localization 2023-11-25 16:02:26 -05:00
123 changed files with 1662 additions and 2391 deletions

View File

@@ -83,7 +83,7 @@ final class ChatModel: ObservableObject {
// current WebRTC call
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
@Published var activeCall: Call?
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
@Published var callCommand: WCallCommand?
@Published var showCallView = false
// remote desktop
@Published var remoteCtrlSession: RemoteCtrlSession?
@@ -267,20 +267,7 @@ final class ChatModel: ObservableObject {
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews
if let i = getChatIndex(cInfo.id) {
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]
}
chats[i].chatItems = [cItem]
if case .rcvNew = cItem.meta.itemStatus {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!)

View File

@@ -605,29 +605,27 @@ func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
throw r
}
func apiConnect(incognito: Bool, connReq: String) async -> (ConnReqType, PendingContactConnection)? {
let (r, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
} else {
return r
return connReqType
}
}
func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
}
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
let m = ChatModel.shared
switch r {
case let .sentConfirmation(_, connection):
return ((.invitation, connection), nil)
case let .sentInvitation(_, connection):
return ((.contact, connection), nil)
case .sentConfirmation: return (.invitation, nil)
case .sentInvitation: return (.contact, nil)
case let .contactAlreadyExists(_, contact):
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id }
}
@@ -1364,6 +1362,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
let m = ChatModel.shared
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .newContactConnection(user, connection):
if active(user) {
await MainActor.run {
m.updateContactConnection(connection)
}
}
case let .contactConnectionDeleted(user, connection):
if active(user) {
await MainActor.run {
m.removeChat(connection.id)
}
}
case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
@@ -1656,40 +1666,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
call.peerMedia = callType.media
call.sharedKey = sharedKey
}
call.callState = .offerReceived
call.peerMedia = callType.media
call.sharedKey = sharedKey
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
m.callCommand = .offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
)
}
case let .callAnswer(_, contact, answer):
await withCall(contact) { call in
await MainActor.run {
call.callState = .answerReceived
}
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
call.callState = .answerReceived
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
}
case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
}
case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation)
}
await withCall(contact) { call in
await m.callCommand.processCommand(.end)
m.callCommand = .end
CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
@@ -1747,9 +1753,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
logger.debug("unsupported event: \(res.responseType)")
}
func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async {
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
if let call = m.activeCall, call.contact.apiId == contact.apiId {
await perform(call)
await MainActor.run { perform(call) }
} else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}

View File

@@ -49,10 +49,10 @@ struct ActiveCallView: View {
}
.onDisappear {
logger.debug("ActiveCallView: disappear")
Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false)
client?.endCall()
}
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
.background(.black)
.preferredColorScheme(.dark)
}
@@ -60,8 +60,19 @@ struct ActiveCallView: View {
private func createWebRTCClient() {
if client == nil && canConnectCall {
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
sendCommandToClient()
}
}
private func sendCommandToClient() {
if call == m.activeCall,
m.activeCall != nil,
let client = client,
let cmd = m.callCommand {
m.callCommand = nil
logger.debug("sendCallCommand: \(cmd.cmdType)")
Task {
await m.callCommand.setClient(client)
await client.sendCallCommand(command: cmd)
}
}
}
@@ -157,10 +168,8 @@ struct ActiveCallView: View {
}
case let .error(message):
logger.debug("ActiveCallView: command error: \(message)")
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
case let .invalid(type):
logger.debug("ActiveCallView: invalid response: \(type)")
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
}
}
}
@@ -246,6 +255,7 @@ struct ActiveCallOverlay: View {
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")")
}
}

View File

@@ -22,7 +22,7 @@ class CallManager {
let m = ChatModel.shared
if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
m.callCommand = .capabilities(media: call.localMedia)
return true
}
return false
@@ -57,21 +57,19 @@ class CallManager {
m.activeCall = call
m.showCallView = true
Task {
await m.callCommand.processCommand(.start(
m.callCommand = .start(
media: invitation.callType.media,
aesKey: invitation.sharedKey,
iceServers: iceServers,
relay: useRelay
))
}
)
}
}
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
m.callCommand = .media(media: media, enable: enable)
return true
}
return false
@@ -96,13 +94,11 @@ class CallManager {
completed()
} else {
logger.debug("CallManager.endCall: ending call...")
m.callCommand = .end
m.activeCall = nil
m.showCallView = false
completed()
Task {
await m.callCommand.processCommand(.end)
await MainActor.run {
m.activeCall = nil
m.showCallView = false
completed()
}
do {
try await apiEndCall(call.contact)
} catch {

View File

@@ -335,50 +335,6 @@ extension WCallResponse: Encodable {
}
}
actor WebRTCCommandProcessor {
private var client: WebRTCClient? = nil
private var commands: [WCallCommand] = []
private var running: Bool = false
func setClient(_ client: WebRTCClient?) async {
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
self.client = client
if client != nil {
await processAllCommands()
} else {
commands.removeAll()
}
}
func processCommand(_ c: WCallCommand) async {
// logger.debug("WebRTC: process command \(c.cmdType)")
commands.append(c)
if !running && client != nil {
await processAllCommands()
}
}
func processAllCommands() async {
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
if let client = client {
running = true
while let c = commands.first, shouldRunCommand(client, c) {
commands.remove(at: 0)
await client.sendCallCommand(command: c)
logger.debug("WebRTC: processed cmd \(c.cmdType)")
}
running = false
}
}
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
switch c {
case .capabilities, .start, .offer, .end: true
default: client.activeCall.wrappedValue != nil
}
}
}
struct ConnectionState: Codable, Equatable {
var connectionState: String
var iceConnectionState: String
@@ -402,12 +358,26 @@ struct ConnectionInfo: Codable, Equatable {
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
}
}
var protocolText: String {
let unknown = NSLocalizedString("unknown", comment: "connection info")
let local = localCandidate?.protocol?.uppercased() ?? unknown
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
let localText = localRelay == local || localCandidate?.relayProtocol == nil
? local
: "\(local) (\(localRelay))"
return local == remote
? localText
: "\(localText) / \(remote)"
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
struct RTCIceCandidate: Codable, Equatable {
var candidateType: RTCIceCandidateType?
var `protocol`: String?
var relayProtocol: String?
var sdpMid: String?
var sdpMLineIndex: Int?
var candidate: String

View File

@@ -21,7 +21,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
struct Call {
var connection: RTCPeerConnection
var iceCandidates: IceCandidates
var iceCandidates: [RTCIceCandidate]
var localMedia: CallMediaType
var localCamera: RTCVideoCapturer?
var localVideoSource: RTCVideoSource?
@@ -33,24 +33,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameDecryptor: RTCFrameDecryptor?
}
actor IceCandidates {
private var candidates: [RTCIceCandidate] = []
func getAndClear() async -> [RTCIceCandidate] {
let cs = candidates
candidates = []
return cs
}
func append(_ c: RTCIceCandidate) async {
candidates.append(c)
}
}
private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void
var activeCall: Binding<Call?>
private var activeCall: Binding<Call?>
private var localRendererAspectRatio: Binding<CGFloat?>
@available(*, unavailable)
@@ -74,7 +60,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
]
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
connection.delegate = self
createAudioSender(connection)
@@ -101,7 +87,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
return Call(
connection: connection,
iceCandidates: IceCandidates(),
iceCandidates: remoteIceCandidates,
localMedia: mediaType,
localCamera: localCamera,
localVideoSource: localVideoSource,
@@ -158,18 +144,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
logger.debug("starting incoming call - create webrtc session")
if activeCall.wrappedValue != nil { endCall() }
let encryption = WebRTCClient.enableEncryption
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
let call = initializeCall(iceServers?.toWebRTCIceServers(), [], media, encryption ? aesKey : nil, relay)
activeCall.wrappedValue = call
let (offer, error) = await call.connection.offer()
if let offer = offer {
resp = .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
capabilities: CallCapabilities(encryption: encryption)
)
self.waitForMoreIceCandidates()
} else {
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
call.connection.offer { answer in
Task {
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
if gotCandidates {
await self.sendCallResponse(.init(
corrId: nil,
resp: .offer(
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
capabilities: CallCapabilities(encryption: encryption)
),
command: command)
)
} else {
self.endCall()
}
}
}
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall.wrappedValue != nil {
@@ -178,21 +172,26 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "accept: encryption is not supported")
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
let call = initializeCall(iceServers?.toWebRTCIceServers(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
activeCall.wrappedValue = call
let pc = call.connection
if let type = offer.type, let sdp = offer.sdp {
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
let (answer, error) = await pc.answer()
if let answer = answer {
pc.answer { answer in
self.addIceCandidates(pc, remoteIceCandidates)
resp = .answer(
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
)
self.waitForMoreIceCandidates()
} else {
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
// Task {
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
Task {
await self.sendCallResponse(.init(
corrId: nil,
resp: .answer(
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
),
command: command)
)
}
// }
}
} else {
resp = .error(message: "accept: remote description is not set")
@@ -235,7 +234,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .ok
}
case .end:
// TODO possibly, endCall should be called before returning .ok
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
endCall()
}
@@ -244,33 +242,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
func getInitialIceCandidates() async -> [RTCIceCandidate] {
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates
}
func waitForMoreIceCandidates() {
Task {
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
if candidates.count > 0 {
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
await self.sendIceCandidates(candidates)
}
}
}
}
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
await self.sendCallResponse(.init(
corrId: nil,
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
command: nil)
)
}
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
@@ -416,13 +387,12 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
audioSessionToDefaults()
}
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
var t: UInt64 = 0
repeat {
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
t += stepMs
await action()
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
let startedAt = DispatchTime.now()
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
}
return success()
}
}
@@ -435,33 +405,25 @@ extension WebRTC.RTCPeerConnection {
optionalConstraints: nil)
}
func offer() async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
offer(for: mediaConstraints()) { (sdp, error) in
self.processSDP(cont, sdp, error)
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
offer(for: mediaConstraints()) { (sdp, error) in
guard let sdp = sdp else {
return
}
}
}
func answer() async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
answer(for: mediaConstraints()) { (sdp, error) in
self.processSDP(cont, sdp, error)
}
}
}
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
if let sdp = sdp {
self.setLocalDescription(sdp, completionHandler: { (error) in
if let error = error {
cont.resume(returning: (nil, error))
} else {
cont.resume(returning: (sdp, nil))
}
completion(sdp)
})
}
}
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
answer(for: mediaConstraints()) { (sdp, error) in
guard let sdp = sdp else {
return
}
self.setLocalDescription(sdp, completionHandler: { (error) in
completion(sdp)
})
} else {
cont.resume(returning: (nil, error))
}
}
}
@@ -517,7 +479,6 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
default: enableSpeaker = false
}
setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .disconnected, .failed: endCall()
default: do {}
}
@@ -530,9 +491,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
Task {
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
}
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
}
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
@@ -547,9 +506,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
lastReceivedMs lastDataReceivedMs: Int32,
changeReason reason: String) {
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
sendConnectedEvent(connection, local: local, remote: remote)
}
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) {
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) {
connection.statistics { (stats: RTCStatisticsReport) in
stats.statistics.values.forEach { stat in
// logger.debug("Stat \(stat.debugDescription)")
@@ -557,25 +517,24 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
let localId = stat.values["localCandidateId"] as? String,
let remoteId = stat.values["remoteCandidateId"] as? String,
let localStats = stats.statistics[localId],
let remoteStats = stats.statistics[remoteId]
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 {
await self.sendCallResponse(.init(
corrId: nil,
resp: .connected(connectionInfo: ConnectionInfo(
localCandidate: RTCIceCandidate(
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
protocol: localStats.values["protocol"] as? String,
sdpMid: nil,
sdpMLineIndex: nil,
candidate: ""
localCandidate: local.toCandidate(
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
localStats.values["protocol"] as? String,
localStats.values["relayProtocol"] as? String
),
remoteCandidate: RTCIceCandidate(
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
protocol: remoteStats.values["protocol"] as? String,
sdpMid: nil,
sdpMLineIndex: nil,
candidate: ""))),
remoteCandidate: remote.toCandidate(
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
remoteStats.values["protocol"] as? String,
remoteStats.values["relayProtocol"] as? String
))),
command: nil)
)
}
@@ -675,10 +634,11 @@ extension RTCIceCandidate {
}
extension WebRTC.RTCIceCandidate {
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
RTCIceCandidate(
candidateType: candidateType,
protocol: `protocol`,
relayProtocol: relayProtocol,
sdpMid: sdpMid,
sdpMLineIndex: Int(sdpMLineIndex),
candidate: sdp

View File

@@ -73,7 +73,6 @@ struct CreateLinkView: View {
Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
m.updateContactConnection(pcc)
connReqInvitation = connReq
contactConnection = pcc
m.connReqInv = connReq

View File

@@ -52,9 +52,6 @@ struct NewChatButton: View {
func addContactAction() {
Task {
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
actionSheet = .createLink(link: connReq, connection: pcc)
}
}
@@ -349,10 +346,7 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
Task {
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) {
await MainActor.run {
ChatModel.shared.updateContactConnection(pcc)
}
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
let crt: ConnReqType
if let plan = connectionPlan {
crt = planToConnReqType(plan)

View File

@@ -505,8 +505,8 @@ public enum ChatResponse: Decodable, Error {
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef, connection: PendingContactConnection)
case sentConfirmation(user: UserRef)
case sentInvitation(user: UserRef)
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
case contactAlreadyExists(user: UserRef, contact: Contact)
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
@@ -605,6 +605,7 @@ public enum ChatResponse: Decodable, Error {
case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode)
case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo])
case newContactConnection(user: UserRef, connection: PendingContactConnection)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
// remote desktop responses/events
case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo])
@@ -612,7 +613,7 @@ public enum ChatResponse: Decodable, Error {
case remoteCtrlConnecting(remoteCtrl_: RemoteCtrlInfo?, ctrlAppInfo: CtrlAppInfo, appVersion: String)
case remoteCtrlSessionCode(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String)
case remoteCtrlConnected(remoteCtrl: RemoteCtrlInfo)
case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason)
case remoteCtrlStopped
// misc
case versionInfo(versionInfo: CoreVersionInfo, chatMigrations: [UpMigration], agentMigrations: [UpMigration])
case cmdOk(user: UserRef?)
@@ -751,6 +752,7 @@ public enum ChatResponse: Decodable, Error {
case .ntfTokenStatus: return "ntfTokenStatus"
case .ntfToken: return "ntfToken"
case .ntfMessages: return "ntfMessages"
case .newContactConnection: return "newContactConnection"
case .contactConnectionDeleted: return "contactConnectionDeleted"
case .remoteCtrlList: return "remoteCtrlList"
case .remoteCtrlFound: return "remoteCtrlFound"
@@ -801,11 +803,11 @@ public enum ChatResponse: Decodable, Error {
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation)
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
@@ -898,6 +900,7 @@ public enum ChatResponse: Decodable, Error {
case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)"
case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))")
case let .newContactConnection(u, connection): return withUser(u, String(describing: connection))
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls)
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)"
@@ -1549,13 +1552,6 @@ public enum RemoteCtrlSessionState: Decodable {
case connected(sessionCode: String)
}
public enum RemoteCtrlStopReason: Decodable {
case discoveryFailed(chatError: ChatError)
case connectionFailed(chatError: ChatError)
case setupFailed(chatError: ChatError)
case disconnected
}
public struct CtrlAppInfo: Decodable {
public var appVersionRange: AppVersionRange
public var deviceName: String

View File

@@ -75,7 +75,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
SimplexService.showBackgroundServiceNoticeIfNeeded()
}
/**

View File

@@ -370,6 +370,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
InfoText(call.callState.text)
val connInfo = call.connectionInfo
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
InfoText(call.encryptionStatus + connInfoText)
}
@@ -584,8 +585,8 @@ fun PreviewActiveCallOverlayVideo() {
localMedia = CallMediaType.Video,
peerMedia = CallMediaType.Video,
connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp")
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
)
),
speakerCanBeEnabled = true,
@@ -610,8 +611,8 @@ fun PreviewActiveCallOverlayAudio() {
localMedia = CallMediaType.Audio,
peerMedia = CallMediaType.Audio,
connectionInfo = ConnectionInfo(
RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
RTCIceCandidate(RTCIceCandidateType.Host, "udp")
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
)
),
speakerCanBeEnabled = true,

View File

@@ -1,15 +0,0 @@
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)
}
}

View File

@@ -37,7 +37,8 @@ import kotlinx.coroutines.flow.*
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState
val scaffoldState: ScaffoldState,
val switchingUsersAndHosts: MutableState<Boolean>
)
@Composable
@@ -101,8 +102,11 @@ fun MainScreen() {
}
Box {
val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state }
val localUserCreated = chatModel.localUserCreated.value
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) }
LaunchedEffect(Unit) {
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
@@ -111,18 +115,14 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete -> {
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
showAdvertiseLAAlert = true
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 userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val scaffoldState = rememberScaffoldState()
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) }
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) }
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
} else {
@@ -137,14 +137,12 @@ fun MainScreen() {
}
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
onboarding == OnboardingStage.LinkAMobile -> LinkAMobile()
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView()
SwitchingUsersView()
}
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
@@ -264,7 +262,7 @@ fun CenterPartOfScreen() {
.background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
Text(stringResource(MR.strings.no_selected_chat))
}
} else {
ModalManager.center.showInView()
@@ -288,7 +286,6 @@ fun DesktopScreen(settingsState: SettingsViewState) {
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
@@ -301,7 +298,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState ) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
Box(
@@ -315,7 +312,7 @@ fun DesktopScreen(settingsState: SettingsViewState) {
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
UserPicker(chatModel, userPickerState) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
@@ -338,26 +335,3 @@ 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
)
}

View File

@@ -2,7 +2,7 @@ package chat.simplex.common.model
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
@@ -43,7 +43,7 @@ object ChatModel {
val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>()
val localUserCreated = mutableStateOf<Boolean?>(null)
val userCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
@@ -51,7 +51,6 @@ object ChatModel {
val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat
val chatId = mutableStateOf<String?>(null)
@@ -109,9 +108,6 @@ object ChatModel {
var updatingChatsMutex: Mutex = Mutex()
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
// remote controller
val remoteHosts = mutableStateListOf<RemoteHostInfo>()
val currentRemoteHost = mutableStateOf<RemoteHostInfo?>(null)
@@ -226,23 +222,8 @@ object ChatModel {
val chat: Chat
if (i >= 0) {
chat = chats[i]
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
cItem
}
}
else -> cItem
}
chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatItems = arrayListOf(cItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
@@ -624,7 +605,6 @@ object ChatModel {
terminalItems.add(item)
}
val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true
}
@@ -2965,14 +2945,6 @@ sealed class RemoteCtrlSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteCtrlSessionState()
}
@Serializable
sealed class RemoteCtrlStopReason {
@Serializable @SerialName("discoveryFailed") class DiscoveryFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("connectionFailed") class ConnectionFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("setupFailed") class SetupFailed(val chatError: ChatError): RemoteCtrlStopReason()
@Serializable @SerialName("disconnected") object Disconnected: RemoteCtrlStopReason()
}
sealed class UIRemoteCtrlSessionState {
object Starting: UIRemoteCtrlSessionState()
object Searching: UIRemoteCtrlSessionState()

View File

@@ -173,8 +173,6 @@ class AppPreferences {
val connectRemoteViaMulticastAuto = mkBoolPreference(SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO, true)
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = settings.getInt(prefName, default),
@@ -319,7 +317,6 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST = "ConnectRemoteViaMulticast"
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
}
}
@@ -362,7 +359,7 @@ object ChatController {
chatModel.users.addAll(users)
if (justStarted) {
chatModel.currentUser.value = user
chatModel.localUserCreated.value = true
chatModel.userCreated.value = true
getUserChatData(null)
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
@@ -382,31 +379,6 @@ object ChatController {
}
}
suspend fun startChatWithoutUser() {
Log.d(TAG, "user: null")
try {
if (chatModel.chatRunning.value == true) return
apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath)
if (appPlatform.isDesktop) {
apiSetRemoteHostsFolder(remoteHostsDir.absolutePath)
}
apiSetXFTPConfig(getXFTPCfg())
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
chatModel.users.clear()
chatModel.currentUser.value = null
chatModel.localUserCreated.value = false
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!)
Log.d(TAG, "startChat: started without user")
} catch (e: Error) {
Log.e(TAG, "failed starting chat without user $e")
throw e
}
}
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
try {
changeActiveUser_(rhId, toUserId, viewPwd)
@@ -430,9 +402,8 @@ object ChatController {
}
suspend fun getUserChatData(rhId: Long?) {
val hasUser = chatModel.currentUser.value != null
chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
chatModel.userAddress.value = apiGetUserAddress(rhId)
chatModel.chatItemTTL.value = getChatItemTTL(rhId)
updatingChatsMutex.withLock {
val chats = apiGetChats(rhId)
chatModel.updateChats(chats)
@@ -501,9 +472,7 @@ object ChatController {
val r = sendCmd(rh, CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
if (rh == null) {
chatModel.localUserCreated.value = false
}
chatModel.userCreated.value = false
return null
}
@@ -922,21 +891,20 @@ object ChatController {
return null
}
suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): PendingContactConnection? {
suspend fun apiConnect(rh: Long?, incognito: Boolean, connReq: String): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user")
return null
return false
}
val r = sendCmd(rh, CC.APIConnect(userId, incognito, connReq))
when {
r is CR.SentConfirmation -> return r.connection
r is CR.SentInvitation -> return r.connection
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.contact_already_exists),
String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), r.contact.displayName)
)
return null
return false
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
@@ -944,7 +912,7 @@ object ChatController {
generalGetString(MR.strings.invalid_connection_link),
generalGetString(MR.strings.please_check_correct_link_and_maybe_ask_for_a_new_one)
)
return null
return false
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP
@@ -953,13 +921,13 @@ object ChatController {
generalGetString(MR.strings.connection_error_auth),
generalGetString(MR.strings.connection_error_auth_desc)
)
return null
return false
}
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r)
}
return null
return false
}
}
}
@@ -1558,6 +1526,16 @@ object ChatController {
fun active(user: UserLike): Boolean = activeUser(rhId, user)
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
when (r) {
is CR.NewContactConnection -> {
if (active(r.user)) {
chatModel.updateContactConnection(rhId, r.connection)
}
}
is CR.ContactConnectionDeleted -> {
if (active(r.user)) {
chatModel.removeChat(rhId, r.connection.id)
}
}
is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact)
@@ -2018,7 +1996,7 @@ object ChatController {
chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err))
}
suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded {
suspend fun switchUIRemoteHost(rhId: Long?) {
// TODO lock the switch so that two switches can't run concurrently?
chatModel.chatId.value = null
ModalManager.center.closeModals()
@@ -2031,10 +2009,7 @@ object ChatController {
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.currentUser.value = user
if (user == null) {
chatModel.chatItems.clear()
chatModel.chats.clear()
}
chatModel.userCreated.value = true
val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) {
chatModel.networkStatuses.clear()
@@ -2044,23 +2019,6 @@ object ChatController {
getUserChatData(rhId)
}
suspend fun showProgressIfNeeded(block: suspend () -> Unit) {
val job = withBGApi {
try {
delay(500)
chatModel.switchingUsersAndHosts.value = true
} catch (e: Throwable) {
chatModel.switchingUsersAndHosts.value = false
}
}
try {
block()
} finally {
job.cancel()
chatModel.switchingUsersAndHosts.value = false
}
}
fun getXFTPCfg(): XFTPFileConfig {
return XFTPFileConfig(minFileSize = 0)
}
@@ -3623,13 +3581,6 @@ sealed class RemoteHostSessionState {
@Serializable @SerialName("connected") data class Connected(val sessionCode: String): RemoteHostSessionState()
}
@Serializable
sealed class RemoteHostStopReason {
@Serializable @SerialName("connectionFailed") data class ConnectionFailed(val chatError: ChatError): RemoteHostStopReason()
@Serializable @SerialName("crashed") data class Crashed(val chatError: ChatError): RemoteHostStopReason()
@Serializable @SerialName("disconnected") object Disconnected: RemoteHostStopReason()
}
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
@@ -3749,8 +3700,8 @@ sealed class CR {
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR()
@Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@@ -3844,6 +3795,7 @@ sealed class CR {
@Serializable @SerialName("callAnswer") class CallAnswer(val user: UserRef, val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("newContactConnection") class NewContactConnection(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR()
// remote events (desktop)
@Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List<RemoteHostInfo>): CR()
@@ -3852,7 +3804,7 @@ sealed class CR {
@Serializable @SerialName("remoteHostSessionCode") class RemoteHostSessionCode(val remoteHost_: RemoteHostInfo?, val sessionCode: String): CR()
@Serializable @SerialName("newRemoteHost") class NewRemoteHost(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostConnected") class RemoteHostConnected(val remoteHost: RemoteHostInfo): CR()
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?, val rhsState: RemoteHostSessionState, val rhStopReason: RemoteHostStopReason): CR()
@Serializable @SerialName("remoteHostStopped") class RemoteHostStopped(val remoteHostId_: Long?): CR()
@Serializable @SerialName("remoteFileStored") class RemoteFileStored(val remoteHostId: Long, val remoteFileSource: CryptoFile): CR()
// remote events (mobile)
@Serializable @SerialName("remoteCtrlList") class RemoteCtrlList(val remoteCtrls: List<RemoteCtrlInfo>): CR()
@@ -3860,7 +3812,7 @@ sealed class CR {
@Serializable @SerialName("remoteCtrlConnecting") class RemoteCtrlConnecting(val remoteCtrl_: RemoteCtrlInfo?, val ctrlAppInfo: CtrlAppInfo, val appVersion: String): CR()
@Serializable @SerialName("remoteCtrlSessionCode") class RemoteCtrlSessionCode(val remoteCtrl_: RemoteCtrlInfo?, val sessionCode: String): CR()
@Serializable @SerialName("remoteCtrlConnected") class RemoteCtrlConnected(val remoteCtrl: RemoteCtrlInfo): CR()
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(val rcsState: RemoteCtrlSessionState, val rcStopReason: RemoteCtrlStopReason): CR()
@Serializable @SerialName("remoteCtrlStopped") class RemoteCtrlStopped(): 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("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
@@ -3992,6 +3944,7 @@ sealed class CR {
is CallAnswer -> "callAnswer"
is CallExtraInfo -> "callExtraInfo"
is CallEnded -> "callEnded"
is NewContactConnection -> "newContactConnection"
is ContactConnectionDeleted -> "contactConnectionDeleted"
is RemoteHostList -> "remoteHostList"
is CurrentRemoteHost -> "currentRemoteHost"
@@ -4046,11 +3999,11 @@ sealed class CR {
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is Invitation -> withUser(user, connReqInvitation)
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, json.encodeToString(connection))
is SentConfirmation -> withUser(user, noDetails())
is SentInvitation -> withUser(user, noDetails())
is SentInvitationToContact -> withUser(user, json.encodeToString(contact))
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
@@ -4138,6 +4091,7 @@ sealed class CR {
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}")
is NewContactConnection -> withUser(user, json.encodeToString(connection))
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
// remote events (mobile)
is RemoteHostList -> json.encodeToString(remoteHosts)

View File

@@ -55,22 +55,10 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
val user = chatController.apiGetActiveUser(null)
if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = null
chatModel.users.clear()
if (appPlatform.isDesktop) {
/**
* Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start
* because of default value of [OnboardingStage.OnboardingComplete]
* */
chatModel.localUserCreated.value = null
if (chatController.listRemoteHosts()?.isEmpty() == true) {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
chatController.startChatWithoutUser()
} else {
chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
}
} else {
val savedOnboardingStage = appPreferences.onboardingStage.get()
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {

View File

@@ -59,9 +59,7 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null)
}
chatModel.controller.changeActiveUser(null, userId, null)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
@@ -74,9 +72,7 @@ abstract class NtfManager {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?
chatModel.controller.showProgressIfNeeded {
chatModel.controller.changeActiveUser(null, userId, null)
}
chatModel.controller.changeActiveUser(null, userId, null)
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true

View File

@@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -76,13 +76,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
disabled = !canCreateProfile(displayName.value),
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
click = {
if (chatModel.localUserCreated.value == true) {
createProfileInProfiles(chatModel, displayName.value, close)
} else {
createProfileInNoProfileSetup(displayName.value, close)
}
},
click = { createProfileInProfiles(chatModel, displayName.value, close) },
)
SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
@@ -174,17 +168,6 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
}
}
fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) {
withApi {
val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi
controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.chatRunning.value = false
controller.startChat(user)
controller.switchUIRemoteHost(null)
close()
}
}
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi {
val rhId = chatModel.remoteHostId()

View File

@@ -127,10 +127,18 @@ sealed class WCallResponse {
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
}
}
val protocolText: String get() {
val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
return if (local == remote) localText else "$localText / $remote"
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?)
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?)
// 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)

View File

@@ -68,14 +68,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) {
if (searchInList.isEmpty()) {
FloatingActionButton(
onClick = {
if (!stopped) {
@@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
) {
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, search = searchInList)
} else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
} else if (!switchingUsersAndHosts.value) {
Box(Modifier.fillMaxSize()) {
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
OnboardingButtons(showNewChatSheet)
@@ -121,11 +121,19 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState) {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
if (switchingUsersAndHosts.value) {
Box(
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
}
}
@Composable
@@ -201,7 +209,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
navigationButton = {
if (showSearch) {
NavigationButtonBack(hideSearchOnBack)
} else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
} else if (chatModel.users.isEmpty()) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
@@ -296,6 +304,17 @@ private fun ToggleFilterButton() {
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
)
}
@Composable
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)

View File

@@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Scaffold(
Modifier.padding(end = endPadding),
@@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = {
UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
})

View File

@@ -26,9 +26,7 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
@@ -40,6 +38,7 @@ import kotlin.math.roundToInt
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
switchingUsersAndHosts: MutableState<Boolean>,
showSettings: Boolean = true,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
@@ -124,10 +123,14 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
scope.launch {
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
job.cancel()
switchingUsersAndHosts.value = false
}
}
}
@@ -159,13 +162,13 @@ fun UserPicker(
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
if (currentRemoteHost == null) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else if (currentRemoteHost != null) {
} else {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
@@ -173,7 +176,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, connecting)
switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
@@ -181,7 +184,7 @@ fun UserPicker(
UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
if (remoteHosts.isNotEmpty() && currentRemoteHost != null) {
LocalDevicePickerItem(false) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
@@ -196,7 +199,7 @@ fun UserPicker(
stopRemoteHostAndReloadHosts(h, false)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
switchToRemoteHost(h, switchingUsersAndHosts, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
@@ -217,18 +220,6 @@ fun UserPicker(
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
} else if (chatModel.desktopNoUserNoRemote) {
CreateInitialProfile {
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
userPickerState.value = AnimatedViewState.HIDING
}
CreateProfile(chat.simplex.common.platform.chatModel, close)
}
}
}
Divider(Modifier.requiredHeight(1.dp))
}
if (showSettings) {
SettingsPickerItem(settingsClicked)
@@ -410,16 +401,6 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) {
}
}
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
@@ -460,15 +441,21 @@ private fun switchToLocalDevice() {
}
}
private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState<Boolean>) {
private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState<Boolean>, connecting: MutableState<Boolean>) {
if (!h.activeHost()) {
withBGApi {
val job = launch {
delay(500)
switchingUsersAndHosts.value = true
}
ModalManager.closeAllModalsEverywhere()
if (h.sessionState != null) {
chatModel.controller.switchUIRemoteHost(h.remoteHostId)
} else {
connectMobileDevice(h, connecting)
}
job.cancel()
switchingUsersAndHosts.value = false
}
} else {
connectMobileDevice(h, connecting)

View File

@@ -264,8 +264,7 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
text,
generalGetString(MR.strings.enter_passphrase),
isValid = ::validKey,
// Don't enable this on desktop since it interfere with key event listener
keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) {
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
),

View File

@@ -4,7 +4,6 @@ import SectionBottomSpacer
import SectionDividerSpaced
import SectionTextFooter
import SectionItemView
import SectionSpacer
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.*
@@ -21,7 +20,6 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -61,9 +59,7 @@ fun DatabaseView(
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) {
startChat(m, chatLastStart, m.chatDbChanged)
}
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
}
}
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
@@ -81,6 +77,7 @@ fun DatabaseView(
m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
@@ -103,13 +100,7 @@ fun DatabaseView(
setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize)
}
},
showSettingsModal,
disconnectAllHosts = {
val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected }
connected.forEachIndexed { index, h ->
controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote())
}
}
showSettingsModal
)
if (progressIndicator.value) {
Box(
@@ -138,6 +129,7 @@ fun DatabaseLayout(
chatDbEncrypted: Boolean?,
passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
@@ -152,43 +144,36 @@ fun DatabaseLayout(
deleteChatAlert: () -> Unit,
deleteAppFilesAndMedia: () -> Unit,
onChatItemTTLSelected: (ChatItemTTL) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
disconnectAllHosts: () -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
val operationsDisabled = !stopped || progressIndicator
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
) {
AppBarTitle(stringResource(MR.strings.your_chat_database))
if (!chatModel.desktopNoUserNoRemote) {
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(".")
}
}
)
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.messages_section_title).uppercase()) {
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
}
val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected }
if (chatModel.localUserCreated.value == true) {
SectionView(stringResource(MR.strings.run_chat_section)) {
if (!toggleEnabled) {
SectionItemView(disconnectAllHosts) {
Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange)
SectionTextFooter(
remember(currentUser?.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.messages_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser?.displayName ?: "")
}
append(".")
}
RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert)
}
)
if (currentRemoteHost == null) {
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.run_chat_section)) {
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
}
SectionTextFooter(
if (stopped) {
@@ -198,96 +183,92 @@ fun DatabaseLayout(
}
)
SectionDividerSpaced()
}
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)
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 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
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))
}
)
}
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()
}
}
@@ -338,7 +319,6 @@ private fun TtlOptions(current: State<ChatItemTTL>, enabled: State<Boolean>, onS
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
enabled: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
@@ -357,7 +337,6 @@ fun RunChatSetting(
stopChatAlert()
}
},
enabled = enabled,
)
}
}
@@ -522,14 +501,13 @@ private fun importArchiveAlert(
m: ChatModel,
importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
progressIndicator: MutableState<Boolean>
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.import_database_question),
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(MR.strings.import_database_confirmation),
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) },
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) },
destructive = true,
)
}
@@ -538,8 +516,7 @@ private fun importArchive(
m: ChatModel,
importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>,
startChat: () -> Unit,
progressIndicator: MutableState<Boolean>
) {
progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI)
@@ -556,10 +533,6 @@ private fun importArchive(
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
}
if (chatModel.localUserCreated.value == false) {
chatModel.chatRunning.value = false
startChat()
}
} else {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import))
@@ -708,6 +681,7 @@ fun PreviewDatabaseLayout() {
chatDbEncrypted = false,
passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@@ -723,7 +697,6 @@ fun PreviewDatabaseLayout() {
deleteAppFilesAndMedia = {},
showSettingsModal = { {} },
onChatItemTTLSelected = {},
disconnectAllHosts = {},
)
}
}

View File

@@ -10,7 +10,6 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.onRightClick
@@ -203,14 +202,13 @@ fun SectionTextFooter(text: String) {
}
@Composable
fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) {
fun SectionTextFooter(text: AnnotatedString) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp,
textAlign = textAlign
fontSize = 14.sp
)
}

View File

@@ -108,7 +108,6 @@ private fun createInvitation(
withApi {
val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get())
if (r != null) {
m.updateContactConnection(rhId, r.second)
connReqInvitation.value = r.first
contactConnection.value = r.second
} else {

View File

@@ -283,11 +283,10 @@ suspend fun connectViaUri(
incognito: Boolean,
connectionPlan: ConnectionPlan?,
close: (() -> Unit)?
) {
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
): Boolean {
val r = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
if (pcc != null) {
chatModel.updateContactConnection(rhId, pcc)
if (r) {
close?.invoke()
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent),
@@ -300,6 +299,7 @@ suspend fun connectViaUri(
hostDevice = hostDevice(rhId),
)
}
return r
}
fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType {

View File

@@ -182,10 +182,6 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) {
val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
if (appPlatform.isDesktop) {
// Make possible to use chat after going to remote device linking and returning back to local profile creation
chatModel.chatRunning.value = false
}
chatModel.controller.startChat(user)
} else {
val users = chatModel.controller.listUsers(rhId)

View File

@@ -1,91 +0,0 @@
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) }
}
}

View File

@@ -3,7 +3,6 @@ package chat.simplex.common.views.onboarding
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,

View File

@@ -43,11 +43,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
if (appPlatform.isAndroid || chatModel.currentUser.value != null) {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
} else {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile)
}
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
}
SetupDatabasePassphraseLayout(
currentKey,
@@ -163,7 +159,10 @@ private fun SetupDatabasePassphraseLayout(
}
},
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }),
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {

View File

@@ -8,7 +8,6 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -100,22 +99,26 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
}
@Composable
expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null)
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
fun OnboardingActionButton(
labelId: StringResource,
onboarding: OnboardingStage?,
border: Boolean,
icon: Painter? = null,
iconColor: Color = MaterialTheme.colors.primary,
onclick: (() -> Unit)?
) {
val modifier = if (border) {
Modifier
.border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50))
.padding(
horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF,
horizontal = DEFAULT_PADDING * 2,
vertical = 4.dp
)
} else {
@@ -128,9 +131,6 @@ fun OnboardingActionButton(
ChatController.appPrefs.onboardingStage.set(onboarding)
}
}, modifier) {
if (icon != null) {
Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor)
}
Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp)
Icon(
painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary,

View File

@@ -13,14 +13,12 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
@@ -99,11 +97,9 @@ fun ConnectMobileLayout(
SectionDividerSpaced(maxBottomPadding = false)
}
SectionView(stringResource(MR.strings.devices).uppercase()) {
if (chatModel.localUserCreated.value == true) {
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) {
if (connectedHost.value == null) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
}
@@ -166,37 +162,26 @@ fun DeviceNameField(
@Composable
private fun ConnectMobileViewLayout(
title: String?,
title: String,
invitation: String?,
deviceName: String?,
sessionCode: String?,
port: String?,
staleQrCode: Boolean = false,
refreshQrCode: () -> Unit = {}
port: String?
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (title != null) {
AppBarTitle(title)
}
AppBarTitle(title)
SectionView {
if (invitation != null && sessionCode == null && port != null) {
Box {
QRCode(
invitation, Modifier
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
if (staleQrCode) {
Box(Modifier.matchParentSize().background(MaterialTheme.colors.background.copy(alpha = 0.9f)), contentAlignment = Alignment.Center) {
SimpleButtonDecorated(stringResource(MR.strings.refresh_qr_code), painterResource(MR.images.ic_refresh), click = refreshQrCode)
}
}
}
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code), textAlign = TextAlign.Center)
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port), textAlign = TextAlign.Center)
QRCode(
invitation, Modifier
.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF)
.aspectRatio(1f)
)
SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code))
SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port))
if (remember { controller.appPrefs.developerTools.state }.value) {
val clipboard = LocalClipboardManager.current
@@ -233,7 +218,6 @@ private fun ConnectMobileViewLayout(
}
}
}
SectionBottomSpacer()
}
}
@@ -253,72 +237,55 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState<Boolean>) {
private fun showAddingMobileDevice(connecting: MutableState<Boolean>) {
ModalManager.start.showModalCloseable { close ->
AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close)
}
}
@Composable
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
val invitation = rememberSaveable { mutableStateOf<String?>(null) }
val port = rememberSaveable { mutableStateOf<String?>(null) }
val pairing = remember { chatModel.remoteHostPairing }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
}
}
val pairing = remember { chatModel.remoteHostPairing }
val sessionCode = when (val state = pairing.value?.second) {
is RemoteHostSessionState.PendingConfirmation -> state.sessionCode
else -> null
}
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
val remoteDeviceName = pairing.value?.first?.hostDeviceName
ConnectMobileViewLayout(
title = if (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = remoteDeviceName,
sessionCode = cachedSessionCode,
port = port.value,
staleQrCode = staleQrCode.value,
refreshQrCode = {
/** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */
var cachedSessionCode by remember { mutableStateOf<String?>(null) }
if (cachedSessionCode == null && sessionCode != null) {
cachedSessionCode = sessionCode
}
val remoteDeviceName = pairing.value?.first?.hostDeviceName
ConnectMobileViewLayout(
title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection),
invitation = invitation.value,
deviceName = remoteDeviceName,
sessionCode = cachedSessionCode,
port = port.value
)
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
withBGApi {
if (chatController.stopRemoteHost(null)) {
startRemoteHost()
staleQrCode.value = false
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
}
}
},
)
val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) }
LaunchedEffect(remember { chatModel.currentRemoteHost }.value) {
if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) {
close()
}
}
KeyChangeEffect(pairing.value) {
if (pairing.value == null) {
close()
}
}
DisposableEffect(Unit) {
withBGApi {
startRemoteHost()
}
onDispose {
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
withBGApi {
chatController.stopRemoteHost(null)
onDispose {
if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) {
withBGApi {
chatController.stopRemoteHost(null)
}
}
chatModel.remoteHostPairing.value = null
}
chatModel.remoteHostPairing.value = null
}
}
}

View File

@@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ClickableText
import chat.simplex.common.views.helpers.*
@@ -170,20 +169,18 @@ fun NetworkAndServersView(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppBarTitle(stringResource(MR.strings.network_and_servers))
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
}
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
}
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
}
if (currentRemoteHost == null && networkUseSocksProxy.value) {
@@ -195,7 +192,7 @@ fun NetworkAndServersView(
}
}
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} else if (!chatModel.desktopNoUserNoRemote) {
} else {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
}

View File

@@ -92,6 +92,7 @@ fun PrivacySettingsView(
chatModel.simplexLinkMode.value = it
})
}
SectionDividerSpaced()
val currentUser = chatModel.currentUser.value
if (currentUser != null) {
@@ -141,42 +142,39 @@ fun PrivacySettingsView(
}
}
if (!chatModel.desktopNoUserNoRemote) {
SectionDividerSpaced()
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Direct) {
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
} else {
count
}
}
if (contactReceiptsOverrides == 0) {
setSendReceiptsContacts(enable, clearOverrides = false)
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Direct) {
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
} else {
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)
count
}
}
)
}
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()
}

View File

@@ -25,7 +25,6 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.SimpleXInfo
@@ -39,39 +38,76 @@ import kotlinx.coroutines.launch
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
SettingsLayout(
profile = user?.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
remember { chatModel.controller.appPrefs.notificationsMode.state },
user?.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = { modalView(chatModel, search) })
}
},
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.start.showModal { VersionInfoView(info) }
if (user != null) {
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
SettingsLayout(
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
remember { chatModel.controller.appPrefs.notificationsMode.state },
user.displayName,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = { modalView(chatModel, search) })
}
}
},
withAuth = ::doWithAuth,
drawerState = drawerState,
)
},
showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
val info = chatModel.controller.apiGetVersion()
if (info != null) {
ModalManager.start.showModal { VersionInfoView(info) }
}
}
},
withAuth = { title, desc, block ->
if (!requireAuth.value) {
block()
} else {
var autoShow = true
ModalManager.fullscreen.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
block()
}
}
LaunchedEffect(Unit) {
if (autoShow) {
autoShow = false
runAuth(title, desc, onFinishAuth)
}
}
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
}
}
}
},
drawerState = drawerState,
)
}
}
val simplexTeamUri =
@@ -79,12 +115,12 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: LocalProfile?,
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
passphraseSaved: Boolean,
notificationsMode: State<NotificationsMode>,
userDisplayName: String?,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
@@ -114,22 +150,13 @@ fun SettingsLayout(
AppBarTitle(stringResource(MR.strings.your_settings))
SectionView(stringResource(MR.strings.settings_section_title_you)) {
val profileHidden = rememberSaveable { mutableStateOf(false) }
if (profile != null) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
} else if (chatModel.localUserCreated.value == false) {
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
closeSettings()
}
CreateProfile(chatModel, close)
} } }, disabled = stopped, extraPadding = true)
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
val profileHidden = rememberSaveable { mutableStateOf(false) }
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
if (appPlatform.isDesktop) {
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true)
} else {
@@ -149,12 +176,10 @@ fun SettingsLayout(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true)
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_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true)
}
SectionDividerSpaced()
@@ -444,42 +469,6 @@ fun PreferenceToggleWithIcon(
}
}
fun doWithAuth(title: String, desc: String, block: () -> Unit) {
val requireAuth = chatModel.controller.appPrefs.performLA.get()
if (!requireAuth) {
block()
} else {
var autoShow = true
ModalManager.fullscreen.showModalCloseable { close ->
val onFinishAuth = { success: Boolean ->
if (success) {
close()
block()
}
}
LaunchedEffect(Unit) {
if (autoShow) {
autoShow = false
runAuth(title, desc, onFinishAuth)
}
}
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
}
}
}
}
private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) {
authenticate(
title,

View File

@@ -21,7 +21,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
@@ -57,9 +56,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
ModalManager.end.closeModals()
}
withBGApi {
controller.showProgressIfNeeded {
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
}
m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim()))
}
},
removeUser = { user ->

View File

@@ -21,8 +21,8 @@
<string name="messages_section_description">ينطبق هذا الإعداد على الرسائل الموجودة في ملف تعريف الدردشة الحالي الخاص بك</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">منصة الرسائل والتطبيقات تحمي خصوصيتك وأمنك.</string>
<string name="profile_is_only_shared_with_your_contacts">يتم مشاركة ملف التعريف مع جهات اتصالك فقط.</string>
<string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة.</string>
<string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى \"%s\". سيتلقى العضو دعوة جديدة.</string>
<string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى %s. سيتم إبلاغ كل فرد في المجموعة.</string>
<string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى %s. سيتلقى العضو دعوة جديدة.</string>
<string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string>
<string name="switch_receiving_address_desc">سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت.</string>
<string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string>
@@ -1412,4 +1412,4 @@
<string name="bad_desktop_address">عنوان سطح المكتب غير صالح</string>
<string name="block_member_desc">سيتم إخفاء كافة الرسائل الجديدة من %s!</string>
<string name="blocked_item_description">محجوب</string>
</resources>
</resources>

View File

@@ -565,7 +565,6 @@
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your SimpleX address</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="about_simplex_chat">About SimpleX Chat</string>
<string name="how_to_use_simplex_chat">How to use it</string>
@@ -1292,8 +1291,8 @@
<string name="change_verb">Change</string>
<string name="switch_verb">Switch</string>
<string name="change_member_role_question">Change group role?</string>
<string name="member_role_will_be_changed_with_notification">The role will be changed to \"%s\". Everyone in the group will be notified.</string>
<string name="member_role_will_be_changed_with_invitation">The role will be changed to \"%s\". The member will receive a new invitation.</string>
<string name="member_role_will_be_changed_with_notification">The role will be changed to %s. Everyone in the group will be notified.</string>
<string name="member_role_will_be_changed_with_invitation">The role will be changed to %s. The member will receive a new invitation.</string>
<string name="connect_via_member_address_alert_title">Connect directly?</string>
<string name="connect_via_member_address_alert_desc">Сonnection request will be sent to this group member.</string>
<string name="error_removing_member">Error removing member</string>
@@ -1660,7 +1659,6 @@
<string name="unlink_desktop_question">Unlink desktop?</string>
<string name="unlink_desktop">Unlink</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="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>
@@ -1691,8 +1689,6 @@
<string name="paste_desktop_address">Paste desktop address</string>
<string name="desktop_device">Desktop</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 -->
<string name="in_developing_title">Coming soon!</string>

View File

@@ -521,7 +521,7 @@
<string name="snd_conn_event_ratchet_sync_agreed">криптирането е съгласувано за %s</string>
<string name="button_edit_group_profile">Редактирай групов профил</string>
<string name="share_text_disappears_at">Изчезва в: %s</string>
<string name="member_role_will_be_changed_with_invitation">Ролята ще бъде променена на \"%s\". Членът ще получи нова покана.</string>
<string name="member_role_will_be_changed_with_invitation">Ролята ще бъде променена на %s. Членът ще получи нова покана.</string>
<string name="conn_level_desc_direct">директна</string>
<string name="renegotiate_encryption">Предоговори криптирането</string>
<string name="group_display_name_field">Групово показвано име:</string>
@@ -925,7 +925,7 @@
<string name="member_will_be_removed_from_group_cannot_be_undone">Членът ще бъде премахнат от групата - това не може да бъде отменено!</string>
<string name="item_info_no_text">няма текст</string>
<string name="button_remove_member">Острани член</string>
<string name="member_role_will_be_changed_with_notification">Ролята ще бъде променена на \"%s\". Всички в групата ще бъдат уведомени.</string>
<string name="member_role_will_be_changed_with_notification">Ролята ще бъде променена на %s. Всички в групата ще бъдат уведомени.</string>
<string name="users_delete_data_only">Само данни за локален профил</string>
<string name="users_delete_with_connections">Профилни и сървърни връзки</string>
<string name="user_mute">Без звук</string>
@@ -1403,4 +1403,4 @@
<string name="compose_send_direct_message_to_connect">Изпрати лично съобщение за свързване</string>
<string name="member_contact_send_direct_message">изпрати лично съобщение</string>
<string name="rcv_group_event_member_created_contact">свързан директно</string>
</resources>
</resources>

View File

@@ -468,7 +468,7 @@
<string name="rcv_group_event_member_left">odešel</string>
<string name="clear_contacts_selection_button">Vyčistit</string>
<string name="switch_verb">Přepnout</string>
<string name="member_role_will_be_changed_with_notification">Role bude změněna na \"%s\". Všichni ve skupině budou informováni.</string>
<string name="member_role_will_be_changed_with_notification">Role bude změněna na %s. Všichni ve skupině budou informováni.</string>
<string name="error_removing_member">Chyba odebírání člena</string>
<string name="error_saving_group_profile">Chyba ukládání profilu skupiny</string>
<string name="network_option_seconds_label">vteřiny</string>
@@ -866,7 +866,7 @@
<string name="role_in_group">Role</string>
<string name="change_role">Změnit roli</string>
<string name="change_verb">Změnit</string>
<string name="member_role_will_be_changed_with_invitation">Role bude změněna na \"%s\". Člen obdrží novou pozvánku.</string>
<string name="member_role_will_be_changed_with_invitation">Role bude změněna na %s. Člen obdrží novou pozvánku.</string>
<string name="error_changing_role">Chyba změny role</string>
<string name="conn_level_desc_direct">přímo</string>
<string name="sending_via">Odesíláno přez</string>
@@ -1406,4 +1406,4 @@
\n- doručenky (až 20 členů).
\n- rychlejší a stabilnější.</string>
<string name="member_contact_send_direct_message">odeslat přímou zprávu</string>
</resources>
</resources>

View File

@@ -797,8 +797,8 @@
<string name="change_verb">Ändern</string>
<string name="switch_verb">Wechseln</string>
<string name="change_member_role_question">Die Mitgliederrolle ändern?</string>
<string name="member_role_will_be_changed_with_notification">Die Mitgliederrolle wird auf \"%s\" geändert. Alle Mitglieder der Gruppe werden benachrichtigt.</string>
<string name="member_role_will_be_changed_with_invitation">Die Mitgliederrolle wird auf \"%s\" geändert. Das Mitglied wird eine neue Einladung erhalten.</string>
<string name="member_role_will_be_changed_with_notification">Die Mitgliederrolle wird auf %s geändert. Alle Mitglieder der Gruppe werden benachrichtigt.</string>
<string name="member_role_will_be_changed_with_invitation">Die Mitgliederrolle wird auf %s geändert. Das Mitglied wird eine neue Einladung erhalten.</string>
<string name="error_removing_member">Fehler beim Entfernen des Mitglieds</string>
<string name="error_changing_role">Fehler beim Ändern der Rolle</string>
<string name="info_row_group">Gruppe</string>
@@ -1605,4 +1605,4 @@
<string name="found_desktop">Gefundener Desktop</string>
<string name="not_compatible">Nicht kompatibel!</string>
<string name="multicast_discoverable_via_local_network">Über das lokale Netzwerk auffindbar</string>
</resources>
</resources>

View File

@@ -734,7 +734,7 @@
<string name="icon_descr_sent_msg_status_unauthorized_send">envío no autorizado</string>
<string name="set_contact_name">Escribe un nombre para el contacto</string>
<string name="unknown_error">Error desconocido</string>
<string name="member_role_will_be_changed_with_notification">El rol cambiará a \"%s\". Se notificará a todos los miembros del grupo.</string>
<string name="member_role_will_be_changed_with_notification">El rol cambiará a %s. Se notificará a todos los miembros del grupo.</string>
<string name="v4_2_security_assessment_desc">La seguridad de SimpleX Chat ha sido auditada por Trail of Bits.</string>
<string name="v4_4_disappearing_messages_desc">Los mensajes enviados se eliminarán una vez transcurrido el tiempo establecido.</string>
<string name="ntf_channel_messages">Mensajes de chat SimpleX</string>
@@ -822,7 +822,7 @@
<string name="update_database_passphrase">Actualizar contraseña base de datos</string>
<string name="group_invitation_tap_to_join_incognito">Pulsa para unirte en modo incógnito</string>
<string name="switch_verb">Cambiar</string>
<string name="member_role_will_be_changed_with_invitation">El rol cambiará a \"%s\". El miembro recibirá una nueva invitación.</string>
<string name="member_role_will_be_changed_with_invitation">El rol cambiará a %s. El miembro recibirá una nueva invitación.</string>
<string name="update_network_settings_confirmation">Actualizar</string>
<string name="update_network_settings_question">¿Actualizar la configuración de red\?</string>
<string name="trying_to_connect_to_server_to_receive_messages">Intentando conectar con el servidor usado para recibir mensajes de este contacto.</string>
@@ -1467,4 +1467,4 @@
<string name="block_member_desc">Los mensajes nuevos de %s estarán ocultos!</string>
<string name="desktop_app_version_is_incompatible">La versión de aplicación del desktop %s no es compatible con esta aplicación.</string>
<string name="blocked_item_description">bloqueado</string>
</resources>
</resources>

View File

@@ -1118,8 +1118,8 @@
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Viestintä- ja sovellusalusta, joka suojaa yksityisyyttäsi ja tietoturvaasi.</string>
<string name="icon_descr_video_call">videopuhelu</string>
<string name="group_info_section_title_num_members"> %1$s JÄSENET</string>
<string name="member_role_will_be_changed_with_notification">Rooli muuttuu muotoon \"%s\". Kaikille ryhmän jäsenille ilmoitetaan asiasta.</string>
<string name="member_role_will_be_changed_with_invitation">Rooli muuttuu muotoon \"%s\". Jäsen saa uuden kutsun.</string>
<string name="member_role_will_be_changed_with_notification">Rooli muuttuu muotoon %s. Kaikille ryhmän jäsenille ilmoitetaan asiasta.</string>
<string name="member_role_will_be_changed_with_invitation">Rooli muuttuu muotoon %s. Jäsen saa uuden kutsun.</string>
<string name="voice_prohibited_in_this_chat">Ääniviestit ovat kiellettyjä tässä keskustelussa.</string>
<string name="v4_3_voice_messages">Ääniviestit</string>
<string name="trying_to_connect_to_server_to_receive_messages">Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta.</string>
@@ -1392,4 +1392,4 @@
<string name="v5_3_new_interface_languages">6 uutta käyttöliittymän kieltä</string>
<string name="v5_3_discover_join_groups">Löydä ryhmiä ja liity niihin</string>
<string name="v5_3_new_desktop_app_descr">Luo uusi profiili työpöytäsovelluksessa. 💻</string>
</resources>
</resources>

View File

@@ -717,7 +717,7 @@
<string name="send_live_message">Envoyer un message dynamique</string>
<string name="send_live_message_desc">Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez</string>
<string name="send_verb">Envoyer</string>
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour «%s». Le membre va recevoir une nouvelle invitation.</string>
<string name="member_role_will_be_changed_with_invitation">Le rôle sera changé pour %s. Le membre va recevoir une nouvelle invitation.</string>
<string name="live">LIVE</string>
<string name="button_add_members">Inviter des membres</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">Vous pouvez partager un lien ou un code QR - n\'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite.</string>
@@ -728,7 +728,7 @@
<string name="only_group_owners_can_change_prefs">Seuls les propriétaires du groupe peuvent modifier les préférences du groupe.</string>
<string name="section_title_for_console">POUR TERMINAL</string>
<string name="change_member_role_question">Changer le rôle du groupe \?</string>
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour «%s». Les membres du groupe seront notifiés.</string>
<string name="member_role_will_be_changed_with_notification">Le rôle sera changé pour %s. Les membres du groupe seront notifiés.</string>
<string name="icon_descr_contact_checked">Contact vérifié⸱e</string>
<string name="clear_contacts_selection_button">Effacer</string>
<string name="num_contacts_selected">%d contact·s sélectionné·e·s</string>
@@ -1524,4 +1524,4 @@
<string name="found_desktop">Bureau trouvé</string>
<string name="not_compatible">Non compatible !</string>
<string name="multicast_discoverable_via_local_network">Accessible via le réseau local</string>
</resources>
</resources>

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 516 B

View File

@@ -829,8 +829,8 @@
<string name="theme_system">Sistema</string>
<string name="network_option_tcp_connection_timeout">Scadenza connessione TCP</string>
<string name="group_is_decentralized">Completamente decentralizzato: visibile solo ai membri.</string>
<string name="member_role_will_be_changed_with_notification">Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica.</string>
<string name="member_role_will_be_changed_with_invitation">Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito.</string>
<string name="member_role_will_be_changed_with_notification">Il ruolo verrà cambiato in %s. Tutti i membri del gruppo riceveranno una notifica.</string>
<string name="member_role_will_be_changed_with_invitation">Il ruolo verrà cambiato in %s. Il membro riceverà un nuovo invito.</string>
<string name="update_network_settings_confirmation">Aggiorna</string>
<string name="update_network_settings_question">Aggiornare le impostazioni di rete\?</string>
<string name="updating_settings_will_reconnect_client_to_all_servers">L\'aggiornamento delle impostazioni riconnetterà il client a tutti i server.</string>
@@ -1519,4 +1519,4 @@
\n- e molto altro!</string>
<string name="waiting_for_mobile_to_connect_on_port"><![CDATA[In attesa che il cellulare si connetta alla porta <i>%s</i>]]></string>
<string name="group_member_role_author">autore</string>
</resources>
</resources>

View File

@@ -1024,7 +1024,7 @@
<string name="group_invitation_tap_to_join">הקישו כדי להצטרף</string>
<string name="network_option_tcp_connection_timeout">תום זמן חיבור TCP</string>
<string name="periodic_notifications_desc">האפליקציה בודקת הודעות חדשות מעת לעת - היא משתמשת בכמה אחוזים מהסוללה ביום. האפליקציה לא משתמשת בהתראות דחיפה - נתונים מהמכשיר שלך לא נשלחים לשרתים.</string>
<string name="member_role_will_be_changed_with_notification">התפקיד ישתנה ל־\"%s\". כל חברי הקבוצה יקבלו הודעה על כך.</string>
<string name="member_role_will_be_changed_with_notification">התפקיד ישתנה ל־%s. כל חברי הקבוצה יקבלו הודעה על כך.</string>
<string name="to_connect_via_link_title">כדי להתחבר באמצעות קישור</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">פלטפורמת ההודעות והיישומים המגנה על הפרטיות והאבטחה שלך.</string>
<string name="alert_text_msg_bad_id">המזהה של ההודעה הבאה שגוי (קטן או שווה להודעה הקודמת).
@@ -1036,7 +1036,7 @@
<string name="thank_you_for_installing_simplex">תודה שהתקנתם את SimpleX Chat!</string>
<string name="this_link_is_not_a_valid_connection_link">קישור זה אינו קישור חיבור תקין!</string>
<string name="theme_colors_section_title">צבעי ערכת נושא</string>
<string name="member_role_will_be_changed_with_invitation">התפקיד ישתנה ל־\"%s\". החבר יקבל הזמנה חדשה.</string>
<string name="member_role_will_be_changed_with_invitation">התפקיד ישתנה ל־%s. החבר יקבל הזמנה חדשה.</string>
<string name="smp_servers_per_user">השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך</string>
<string name="first_platform_without_user_ids">הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה.</string>
<string name="next_generation_of_private_messaging">הדור הבא של תקשורת פרטית</string>
@@ -1407,4 +1407,4 @@
<string name="settings_is_storing_in_clear_text">ביטוי הסיסמה מאוחסן בהגדרות כטקסט רגיל.</string>
<string name="member_contact_send_direct_message">שלח הודעה ישירה</string>
<string name="rcv_group_event_member_created_contact">מחובר ישירות</string>
</resources>
</resources>

View File

@@ -789,9 +789,9 @@
<string name="num_contacts_selected">%d 連絡先が選択中</string>
<string name="snd_group_event_member_deleted">除名しました: %1$s</string>
<string name="switch_verb">切り替える</string>
<string name="member_role_will_be_changed_with_notification">役割が%sとなります。グループの全員に通知が出ます。</string>
<string name="member_role_will_be_changed_with_notification">役割が %s となります。グループの全員に通知が出ます。</string>
<string name="conn_stats_section_title_servers">サーバ</string>
<string name="member_role_will_be_changed_with_invitation">役割が%sとなります。メンバーに新しい招待が届きます。</string>
<string name="member_role_will_be_changed_with_invitation">役割が %s となります。メンバーに新しい招待が届きます。</string>
<string name="group_is_decentralized">グループは完全分散型で、メンバーしか内容を見れません。</string>
<string name="network_options_save">保存</string>
<string name="update_network_settings_question">ネットワーク設定を更新しますか?</string>
@@ -1403,4 +1403,4 @@
<string name="v5_3_simpler_incognito_mode_descr">接続時にシークレットモードを切り替えます。</string>
<string name="member_contact_send_direct_message">ダイレクトメッセージを送る</string>
<string name="rcv_group_event_member_created_contact">直接接続中</string>
</resources>
</resources>

View File

@@ -638,7 +638,7 @@
<string name="messages_section_title">메시지</string>
<string name="messages_section_description">이 설정은 현재 내 프로필의 메시지에 적용되어요.</string>
<string name="member_info_section_title_member">멤버</string>
<string name="member_role_will_be_changed_with_invitation">역할이 \"%s\"(으)로 변경되고, 회원은 새로운 초대를 받게 될 거예요.</string>
<string name="member_role_will_be_changed_with_invitation">역할이 %s(으)로 변경되고, 회원은 새로운 초대를 받게 될 거예요.</string>
<string name="network_options_revert">되돌리기</string>
<string name="message_deletion_prohibited">이 채팅에서는 메시지 영구 삭제가 허용되지 않았어요.</string>
<string name="leave_group_button">나가기</string>
@@ -683,7 +683,7 @@
<string name="leave_group_question">그룹에서 나갈까요\?</string>
<string name="mtr_error_no_down_migration">데이터베이스 버전이 앱보다 최신이지만, 다음에 대한 다운 마이그레이션 없음: %s</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">멤버가 그룹에서 제거되어요. 이 작업은 되돌릴 수 없어요!</string>
<string name="member_role_will_be_changed_with_notification">역할이 \"%s\"(으)로 변경되어요. 그룹의 모든 멤버에게 알림이 전송됩니다.</string>
<string name="member_role_will_be_changed_with_notification">역할이 %s(으)로 변경되어요. 그룹의 모든 멤버에게 알림이 전송됩니다.</string>
<string name="network_options_reset_to_defaults">기본값으로 재설정</string>
<string name="notification_preview_mode_message">메시지 내용</string>
<string name="notification_preview_mode_message_desc">대화 상대 이름 및 메시지 표시</string>
@@ -945,4 +945,4 @@
<string name="waiting_for_image">이미지 기다리는 중</string>
<string name="voice_message">음성 메시지</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d개의 메시지의 해독에 실패했습니다.</string>
</resources>
</resources>

View File

@@ -465,8 +465,8 @@
<string name="database_restore_error">Duomenų bazės atkūrimo klaida</string>
<string name="alert_message_no_group">Šios grupės daugiau nebėra.</string>
<string name="network_option_seconds_label">sek.</string>
<string name="member_role_will_be_changed_with_notification">Vaidmuo bus pakeistas į %s. Visiems grupėje bus pranešta.</string>
<string name="member_role_will_be_changed_with_invitation">Vaidmuo bus pakeistas į %s. Narys gaus naują pakvietimą.</string>
<string name="member_role_will_be_changed_with_notification">Vaidmuo bus pakeistas į %s. Visiems grupėje bus pranešta.</string>
<string name="member_role_will_be_changed_with_invitation">Vaidmuo bus pakeistas į %s. Narys gaus naują pakvietimą.</string>
<string name="chat_preferences">Pokalbio nuostatos</string>
<string name="contact_preferences">Adresato nuostatos</string>
<string name="join_group_button">Prisijungti</string>
@@ -616,4 +616,4 @@
<string name="cannot_access_keychain">Negalima pasiekti \"Keystore\", kad išsaugotumėte duomenų bazės slaptažodį</string>
<string name="icon_descr_cancel_file_preview">Atšaukti failo peržiūrą</string>
<string name="icon_descr_cancel_image_preview">Atšaukti vaizdo peržiūrą</string>
</resources>
</resources>

View File

@@ -280,7 +280,7 @@
<string name="custom_time_unit_weeks">ആഴ്ചകൾ</string>
<string name="icon_descr_sent_msg_status_unauthorized_send">അനധികൃത അയക്കുക</string>
<string name="switch_receiving_address_question">സ്വീകരിക്കുന്ന വിലാസം മാറണോ\?</string>
<string name="member_role_will_be_changed_with_invitation">കര്‍ത്തവ്യം \"%s\" ആയി മാറ്റും. അംഗത്തിന് പുതിയ ക്ഷണം ലഭിക്കും.</string>
<string name="member_role_will_be_changed_with_invitation">കര്‍ത്തവ്യം %s ആയി മാറ്റും. അംഗത്തിന് പുതിയ ക്ഷണം ലഭിക്കും.</string>
<string name="la_lock_mode_system">സംവിധാനം പ്രാമാണീകരണം</string>
<string name="skip_inviting_button">അംഗങ്ങളെ ക്ഷണിക്കുന്നത് ഒഴിവാക്കുക</string>
<string name="save_welcome_message_question">സ്വാഗത സന്ദേശം സംരക്ഷിക്കണോ\?</string>
@@ -418,4 +418,4 @@
<string name="feature_offered_item">%s വാഗ്ദാനം ചെയ്തു</string>
<string name="search_verb">തിരയുക</string>
<string name="la_mode_off">ഓഫാണ്</string>
</resources>
</resources>

View File

@@ -772,7 +772,7 @@
<string name="group_info_section_title_num_members">%1$s LEDEN</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">U kunt een link of een QR-code delen. Iedereen kan lid worden van de groep. U verliest geen leden van de groep als u deze later verwijdert.</string>
<string name="switch_verb">Wijzig</string>
<string name="member_role_will_be_changed_with_notification">De rol wordt gewijzigd in \"%s\". Iedereen in de groep wordt op de hoogte gebracht.</string>
<string name="member_role_will_be_changed_with_notification">De rol wordt gewijzigd in %s. Iedereen in de groep wordt op de hoogte gebracht.</string>
<string name="receiving_via">Ontvang via</string>
<string name="save_group_profile">Groep profiel opslaan</string>
<string name="group_is_decentralized">Volledig gedecentraliseerd alleen zichtbaar voor leden.</string>
@@ -805,7 +805,7 @@
<string name="button_remove_member">Lid verwijderen</string>
<string name="role_in_group">Rol</string>
<string name="button_send_direct_message">Direct bericht sturen</string>
<string name="member_role_will_be_changed_with_invitation">De rol wordt gewijzigd in \"%s\". De gebruiker ontvangt een nieuwe uitnodiging.</string>
<string name="member_role_will_be_changed_with_invitation">De rol wordt gewijzigd in %s. De gebruiker ontvangt een nieuwe uitnodiging.</string>
<string name="sending_via">Verzenden via</string>
<string name="conn_stats_section_title_servers">SERVERS</string>
<string name="network_options_reset_to_defaults">Resetten naar standaardwaarden</string>
@@ -1522,4 +1522,4 @@
<string name="found_desktop">Desktop gevonden</string>
<string name="not_compatible">Niet compatibel!</string>
<string name="multicast_discoverable_via_local_network">Vindbaar via lokaal netwerk</string>
</resources>
</resources>

View File

@@ -742,7 +742,7 @@
<string name="switch_verb">Przełącz</string>
<string name="switch_receiving_address">Zmień adres odbioru</string>
<string name="group_is_decentralized">W pełni zdecentralizowana widoczna tylko dla członków.</string>
<string name="member_role_will_be_changed_with_invitation">Rola zostanie zmieniona na \"%s\". Członek otrzyma nowe zaproszenie.</string>
<string name="member_role_will_be_changed_with_invitation">Rola zostanie zmieniona na %s. Członek otrzyma nowe zaproszenie.</string>
<string name="group_welcome_title">Wiadomość powitalna</string>
<string name="cant_delete_user_profile">Nie można usunąć profilu użytkownika!</string>
<string name="users_delete_question">Usunąć profil czatu\?</string>
@@ -1004,7 +1004,7 @@
<string name="thank_you_for_installing_simplex">Dziękujemy za zainstalowanie SimpleX Chat!</string>
<string name="should_be_at_least_one_visible_profile">Powinien istnieć co najmniej jeden widoczny profil użytkownika.</string>
<string name="moderate_message_will_be_marked_warning">Wiadomość zostanie oznaczona jako moderowana dla wszystkich członków.</string>
<string name="member_role_will_be_changed_with_notification">Rola zostanie zmieniona na \"%s\". Wszyscy w grupie zostaną powiadomieni.</string>
<string name="member_role_will_be_changed_with_notification">Rola zostanie zmieniona na %s. Wszyscy w grupie zostaną powiadomieni.</string>
<string name="delete_files_and_media_desc">Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną.</string>
<string name="switch_receiving_address_desc">Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online.</string>
<string name="this_link_is_not_a_valid_connection_link">Ten link nie jest prawidłowym linkiem połączenia!</string>
@@ -1524,4 +1524,4 @@
<string name="disconnect_desktop_question">Rozłączyć komputer?</string>
<string name="loading_remote_file_desc">Proszę poczekać na załadowanie pliku z połączonego telefonu</string>
<string name="verify_connection">Zweryfikuj połączenie</string>
</resources>
</resources>

View File

@@ -707,7 +707,7 @@
<string name="mtr_error_different">migração diferente no aplicativo/banco de dados: %s / %s</string>
<string name="invite_to_group_button">Convidar para o grupo</string>
<string name="no_contacts_to_add">Sem contatos para adicionar</string>
<string name="member_role_will_be_changed_with_notification">A função será alterada para \"%s\". Todos no grupo serão notificados.</string>
<string name="member_role_will_be_changed_with_notification">A função será alterada para %s. Todos no grupo serão notificados.</string>
<string name="user_mute">Mutar</string>
<string name="should_be_at_least_one_visible_profile">Deve haver pelo menos um perfil de usuário visível.</string>
<string name="only_you_can_send_voice">Somente você pode enviar mensagens de voz.</string>
@@ -823,7 +823,7 @@
<string name="settings_section_title_experimenta">EXPERIMENTAL</string>
<string name="snd_conn_event_switch_queue_phase_completed">você alterou o endereço</string>
<string name="database_upgrade">Atualização do banco de dados</string>
<string name="member_role_will_be_changed_with_invitation">A função será alterada para \"%s\". O membro receberá um novo convite.</string>
<string name="member_role_will_be_changed_with_invitation">A função será alterada para %s. O membro receberá um novo convite.</string>
<string name="only_group_owners_can_change_prefs">Somente os proprietários do grupo podem alterar as preferências do grupo.</string>
<string name="button_add_welcome_message">Adicionar mensagem de boas-vindas</string>
<string name="group_welcome_title">Mensagem de boas-vindas</string>
@@ -1349,4 +1349,4 @@
<string name="no_selected_chat">Sem chat selecionado</string>
<string name="privacy_message_draft">Rascunho de mensagem</string>
<string name="send_receipts_disabled">desativado</string>
</resources>
</resources>

View File

@@ -802,8 +802,8 @@
<string name="change_verb">Поменять</string>
<string name="switch_verb">Переключить</string>
<string name="change_member_role_question">Поменять роль в группе?</string>
<string name="member_role_will_be_changed_with_notification">Роль будет изменена на \"%s\". Все в группе получат сообщение.</string>
<string name="member_role_will_be_changed_with_invitation">Роль будет изменена на \"%s\". Будет отправлено новое приглашение.</string>
<string name="member_role_will_be_changed_with_notification">Роль будет изменена на %s. Все в группе получат сообщение.</string>
<string name="member_role_will_be_changed_with_invitation">Роль будет изменена на %s. Будет отправлено новое приглашение.</string>
<string name="error_removing_member">Ошибка при удалении члена группы</string>
<string name="error_changing_role">Ошибка при изменении роли</string>
<string name="info_row_group">Группа</string>
@@ -1606,4 +1606,4 @@
<string name="not_compatible">Несовместимая версия!</string>
<string name="group_member_role_author">автор</string>
<string name="multicast_discoverable_via_local_network">Найти через локальную сеть</string>
</resources>
</resources>

View File

@@ -1029,7 +1029,7 @@
<string name="v5_0_polish_interface_descr">ขอบคุณผู้ใช้ มีส่วนร่วมผ่าน Weblate!</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">แพลตฟอร์มการส่งข้อความและแอปพลิเคชันที่ปกป้องความเป็นส่วนตัวและความปลอดภัยของคุณ</string>
<string name="next_generation_of_private_messaging">การส่งข้อความส่วนตัวรุ่นต่อไป</string>
<string name="member_role_will_be_changed_with_notification">บทบาทจะถูกเปลี่ยนเป็น \"%s\" ทุกคนในกลุ่มจะได้รับแจ้ง</string>
<string name="member_role_will_be_changed_with_notification">บทบาทจะถูกเปลี่ยนเป็น %s ทุกคนในกลุ่มจะได้รับแจ้ง</string>
<string name="delete_files_and_media_desc">การดำเนินการนี้ไม่สามารถยกเลิกได้ ไฟล์และสื่อที่ได้รับและส่งทั้งหมดจะถูกลบ รูปภาพความละเอียดต่ำจะยังคงอยู่</string>
<string name="to_reveal_profile_enter_password">หากต้องการเปิดเผยโปรไฟล์ที่ซ่อนอยู่ของคุณ ให้ป้อนรหัสผ่านแบบเต็มในช่องค้นหาในหน้าโปรไฟล์แชทของคุณ</string>
<string name="network_session_mode_transport_isolation">การแยกการขนส่ง</string>
@@ -1210,7 +1210,7 @@
<string name="button_welcome_message">ข้อความต้อนรับ</string>
<string name="you_can_share_group_link_anybody_will_be_able_to_connect">คุณสามารถแชร์ลิงก์หรือคิวอาร์โค้ดได้ ทุกคนจะสามารถเข้าร่วมกลุ่มได้ คุณจะไม่สูญเสียสมาชิกของกลุ่มหากคุณลบในภายหลัง</string>
<string name="you_can_share_this_address_with_your_contacts">คุณสามารถแบ่งปันที่อยู่นี้กับผู้ติดต่อของคุณเพื่อให้พวกเขาเชื่อมต่อกับ %s</string>
<string name="member_role_will_be_changed_with_invitation">บทบาทจะถูกเปลี่ยนเป็น \"%s\" สมาชิกจะได้รับคำเชิญใหม่</string>
<string name="member_role_will_be_changed_with_invitation">บทบาทจะถูกเปลี่ยนเป็น %s สมาชิกจะได้รับคำเชิญใหม่</string>
<string name="group_welcome_title">ข้อความต้อนรับ</string>
<string name="group_main_profile_sent">โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม</string>
<string name="update_network_settings_confirmation">อัปเดต</string>
@@ -1352,4 +1352,4 @@
<string name="in_reply_to">ในการตอบกลับถึง</string>
<string name="no_history">ไม่มีประวัติ</string>
<string name="conn_event_ratchet_sync_ok">encryptionใช้ได้</string>
</resources>
</resources>

View File

@@ -142,14 +142,14 @@
<string name="save_and_notify_contact">Kaydet ve kişiyi bilgilendir</string>
<string name="save_passphrase_in_keychain">Parolayı, Keystore\'a kaydet.</string>
<string name="icon_descr_audio_on">Ses açık</string>
<string name="member_role_will_be_changed_with_notification">Yetki, \"%s\" olarak değiştirelecek. Gruptaki herkes bilgilendirilecek.</string>
<string name="member_role_will_be_changed_with_notification">Yetki, %s olarak değiştirelecek. Gruptaki herkes bilgilendirilecek.</string>
<string name="calls_prohibited_with_this_contact">Sesli/görüntülü aramalar yasaktır.</string>
<string name="la_auth_failed">Kimlik doğrulama başarısız</string>
<string name="icon_descr_address">SimpleX adresi</string>
<string name="moderate_message_will_be_deleted_warning">Mesaj, tüm üyeler için silinecek.</string>
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">Gizliliğinizi ve güvenliğinizi koruyan mesajlaşma ve uygulama platformu.</string>
<string name="settings_section_title_incognito">Gizlilik kipi</string>
<string name="member_role_will_be_changed_with_invitation">Yetki, \"%s\" olarak değiştirilecek. Üye, yeni bir davet alacak.</string>
<string name="member_role_will_be_changed_with_invitation">Yetki, %s olarak değiştirilecek. Üye, yeni bir davet alacak.</string>
<string name="role_in_group">Yetki</string>
<string name="allow_voice_messages_question">Sesli mesajlara izin ver\?</string>
<string name="users_add">Profil ekle</string>
@@ -1071,4 +1071,4 @@
<string name="stop_sharing">Paylaşmayı durdur</string>
<string name="stop_rcv_file__title">Dosya almayı durdur?</string>
<string name="stop_chat_question">Sohbeti durdur?</string>
</resources>
</resources>

View File

@@ -736,8 +736,8 @@
<string name="share_text_disappears_at">Зникає в: %s</string>
<string name="item_info_current">(поточний)</string>
<string name="button_remove_member">Видалити учасника</string>
<string name="member_role_will_be_changed_with_notification">Роль буде змінено на \"%s\". Всі учасники групи будуть повідомлені про це.</string>
<string name="member_role_will_be_changed_with_invitation">Роль буде змінено на \"%s\". Учасник отримає нове запрошення.</string>
<string name="member_role_will_be_changed_with_notification">Роль буде змінено на %s. Всі учасники групи будуть повідомлені про це.</string>
<string name="member_role_will_be_changed_with_invitation">Роль буде змінено на %s. Учасник отримає нове запрошення.</string>
<string name="info_row_group">Група</string>
<string name="group_welcome_title">Вітальне повідомлення</string>
<string name="group_profile_is_stored_on_members_devices">Профіль групи зберігається на пристроях учасників, а не на серверах.</string>
@@ -1459,4 +1459,4 @@
<string name="block_member_desc">Всі повідомлення від %s будуть приховані</string>
<string name="rcv_group_event_member_created_contact">підключений безпосередньо</string>
<string name="blocked_item_description">заблоковано</string>
</resources>
</resources>

View File

@@ -505,7 +505,7 @@
<string name="show_call_on_lock_screen">显示</string>
<string name="you_must_use_the_most_recent_version_of_database">您只能在一台设备上使用最新版本的聊天数据库,否则您可能会停止接收来自某些联系人的消息。</string>
<string name="new_passphrase">新密码……</string>
<string name="member_role_will_be_changed_with_notification">该角色将更改为%s。群组中每个人都会收到通知。</string>
<string name="member_role_will_be_changed_with_notification">该角色将更改为%s。群组中每个人都会收到通知。</string>
<string name="chat_lock">SimpleX 锁定</string>
<string name="periodic_notifications">定期通知</string>
<string name="notifications_mode_periodic">定期启动</string>
@@ -739,7 +739,7 @@
<string name="image_decoding_exception_desc">图像无法解码。 请尝试不同的图像或联系开发者。</string>
<string name="theme">主题</string>
<string name="delete_files_and_media_desc">此操作无法撤消——所有接收和发送的文件和媒体都将被删除。 低分辨率图片将保留。</string>
<string name="member_role_will_be_changed_with_invitation">角色将更改为%s。 该成员将收到新的邀请。</string>
<string name="member_role_will_be_changed_with_invitation">角色将更改为%s。 该成员将收到新的邀请。</string>
<string name="enable_automatic_deletion_message">此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。</string>
<string name="this_QR_code_is_not_a_link">此二维码不是链接!</string>
<string name="switch_receiving_address_desc">接收地址将变更到不同的服务器。地址更改将在发件人上线后完成。</string>
@@ -1524,4 +1524,4 @@
<string name="found_desktop">找到了桌面</string>
<string name="not_compatible">不兼容!</string>
<string name="multicast_discoverable_via_local_network">可通过本地网络发现</string>
</resources>
</resources>

View File

@@ -605,8 +605,8 @@
<string name="button_create_group_link">建立連結</string>
<string name="delete_link_question">刪除連結?</string>
<string name="button_remove_member">移除成員</string>
<string name="member_role_will_be_changed_with_notification">成員的身份會修改為 \"%s\"。所有在群組內的成員都接收到通知。</string>
<string name="member_role_will_be_changed_with_invitation">成員的身份會修改為 \"%s\"。該成員將接收到新的邀請。</string>
<string name="member_role_will_be_changed_with_notification">成員的身份會修改為 %s。所有在群組內的成員都接收到通知。</string>
<string name="member_role_will_be_changed_with_invitation">成員的身份會修改為 %s。該成員將接收到新的邀請。</string>
<string name="network_status">網路狀態</string>
<string name="network_options_reset_to_defaults">重置為預設值</string>
<string name="incognito_info_share">當你與某人分享已啟用匿名聊天模式的個人檔案時,此個人檔案將用於他們邀請你參加的群組。</string>
@@ -1255,4 +1255,4 @@
\n- 編輯紀錄。</string>
<string name="search_verb">搜尋</string>
<string name="la_mode_off">已關閉</string>
</resources>
</resources>

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.TerminalView
@@ -30,30 +31,10 @@ import java.io.File
val simplexWindowState = SimplexWindowState()
fun showApp() = application {
// Creates file if not exists; comes with proper defaults
val state = getStoredWindowState()
val windowState: WindowState = rememberWindowState(
placement = WindowPlacement.Floating,
width = state.width.dp,
height = state.height.dp,
position = WindowPosition(state.x.dp, state.y.dp)
)
LaunchedEffect(
windowState.position.x.value,
windowState.position.y.value,
windowState.size.width.value,
windowState.size.height.value
) {
storeWindowState(WindowPositionSize(
x = windowState.position.x.value.toInt(),
y = windowState.position.y.value.toInt(),
width = windowState.size.width.value.toInt(),
height = windowState.size.height.value.toInt()
))
}
// For some reason on Linux actual width will be 10.dp less after specifying it here. If we specify 1366,
// it will show 1356. But after that we can still update it to 1366 by changing window state. Just making it +10 now here
val width = if (desktopPlatform.isLinux()) 1376.dp else 1366.dp
val windowState = rememberWindowState(placement = WindowPlacement.Floating, width = width, height = 768.dp)
simplexWindowState.windowState = windowState
// Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.appLanguage.state }.value != "") {
@@ -143,7 +124,7 @@ fun showApp() = application {
var hiddenUntilRestart by remember { mutableStateOf(false) }
if (!hiddenUntilRestart) {
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp)
Window(state = cWindowState, onCloseRequest = { hiddenUntilRestart = true }, title = stringResource(MR.strings.chat_console)) {
Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) {
SimpleXTheme {
TerminalView(ChatModel) { hiddenUntilRestart = true }
}

View File

@@ -1,36 +0,0 @@
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))

View File

@@ -136,6 +136,7 @@ private fun SendStateUpdates() {
.collect { call ->
val state = call.callState.text
val connInfo = call.connectionInfo
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
val description = call.encryptionStatus + connInfoText
chatModel.callCommand.add(WCallCommand.Description(state, description))

View File

@@ -1,23 +0,0 @@
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)
}
}

View File

@@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 90a8fc91d35c578c3b52ad296a6f1df715da2278
tag: 757b7eec81341d8560a326deab303bb6fb6a26a3
source-repository-package
type: git
@@ -45,3 +45,8 @@ source-repository-package
type: git
location: https://github.com/simplex-chat/android-support.git
tag: 9aa09f148089d6752ce563b14c2df1895718d806
source-repository-package
type: git
location: https://github.com/simplex-chat/network-transport.git
tag: 0013798272a683e35ca38d2fdaf480942311fba8

View File

@@ -8,7 +8,7 @@ title: App settings
To open app settings:
- Open the app.
- Tap on your user profile image in the upper left-hand of the screen.
- Tap on your user profile image in the upper right-hand of the screen.
- If you have more than one profile, tap the current profile again or choose Settings.
## Your profile settings

View File

@@ -1,110 +0,0 @@
# 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.

View File

@@ -1,35 +0,0 @@
# 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.

View File

@@ -1,30 +0,0 @@
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: []

View File

@@ -32,10 +32,10 @@ dependencies:
- filepath == 1.4.*
- http-types == 0.12.*
- http2 >= 4.2.2 && < 4.3
- memory >= 0.15 && < 0.19
- mtl >= 2.2 && < 3
- memory == 0.18.*
- mtl == 2.3.*
- network >= 3.1.2.7 && < 3.2
- network-transport >= 0.5.6 && < 0.6
- network-transport == 0.5.6
- optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*
- random >= 1.1 && < 1.3
@@ -45,14 +45,14 @@ dependencies:
- socks == 0.6.*
- sqlcipher-simple == 0.4.*
- stm == 2.5.*
- template-haskell >= 2.16 && < 2.21
- template-haskell == 2.20.*
- terminal == 0.2.*
- text >= 2.0 && < 3
- text == 2.0.*
- time == 1.9.*
- tls >= 1.6.0 && < 1.7
- unliftio == 0.2.*
- unliftio-core == 0.2.*
- zip >= 1.7 && < 2.1
- zip == 2.0.*
flags:
swift:

View File

@@ -86,6 +86,7 @@ export type ChatResponse =
| CRGroupUpdated
| CRUserContactLinkSubscribed
| CRUserContactLinkSubError
| CRNewContactConnection
| CRContactConnectionDeleted
| CRMessageError
| CRChatCmdError
@@ -730,6 +731,12 @@ export interface CRUserContactLinkSubError extends CR {
chatError: ChatError
}
export interface CRNewContactConnection extends CR {
type: "newContactConnection"
user: User
connection: PendingContactConnection
}
export interface CRContactConnectionDeleted extends CR {
type: "contactConnectionDeleted"
user: User

View File

@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."90a8fc91d35c578c3b52ad296a6f1df715da2278" = "1yjixh6b2s1law3kh885fsbr1inv1r7iy4g9g2bn6j4ygdn8vlzy";
"https://github.com/simplex-chat/simplexmq.git"."757b7eec81341d8560a326deab303bb6fb6a26a3" = "0kqnxpyz8v43802fncqxdg6i2ni70yv7jg7a1nbkny1w937fwf40";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
@@ -7,4 +7,5 @@
"https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr";
"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/network-transport.git"."0013798272a683e35ca38d2fdaf480942311fba8" = "0dnn62apgvc248df0m8ib7phrzn63wm0xs71xvlypv52j6cgwzkb";
}

View File

@@ -124,7 +124,6 @@ library
Simplex.Chat.Migrations.M20231107_indexes
Simplex.Chat.Migrations.M20231113_group_forward
Simplex.Chat.Migrations.M20231114_remote_control
Simplex.Chat.Migrations.M20231126_remote_ctrl_address
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
@@ -184,10 +183,10 @@ library
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network >=3.1.2.7 && <3.2
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -197,14 +196,14 @@ library
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -236,10 +235,10 @@ executable simplex-bot
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network >=3.1.2.7 && <3.2
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -250,14 +249,14 @@ executable simplex-bot
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -289,10 +288,10 @@ executable simplex-bot-advanced
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network >=3.1.2.7 && <3.2
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -303,14 +302,14 @@ executable simplex-bot-advanced
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -344,10 +343,10 @@ executable simplex-broadcast-bot
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network >=3.1.2.7 && <3.2
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -358,14 +357,14 @@ executable simplex-broadcast-bot
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -398,10 +397,10 @@ executable simplex-chat
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network ==3.1.*
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -412,15 +411,15 @@ executable simplex-chat
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, websockets ==0.12.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -456,10 +455,10 @@ executable simplex-directory-service
, filepath ==1.4.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network >=3.1.2.7 && <3.2
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -470,14 +469,14 @@ executable simplex-directory-service
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON
@@ -540,10 +539,10 @@ test-suite simplex-chat-test
, hspec ==2.11.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, memory >=0.15 && <0.19
, mtl >=2.2 && <3
, memory ==0.18.*
, mtl ==2.3.*
, network ==3.1.*
, network-transport >=0.5.6 && <0.6
, network-transport ==0.5.6
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, random >=1.1 && <1.3
@@ -555,14 +554,14 @@ test-suite simplex-chat-test
, socks ==0.6.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, template-haskell >=2.16 && <2.21
, template-haskell ==2.20.*
, terminal ==0.2.*
, text >=2.0 && <3
, text ==2.0.*
, time ==1.9.*
, tls >=1.6.0 && <1.7
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, zip >=1.7 && <2.1
, zip ==2.0.*
default-language: Haskell2010
if flag(swift)
cpp-options: -DswiftJSON

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,7 @@ import qualified Data.Text as T
import qualified Database.SQLite3 as SQL
import Simplex.Chat.Controller
import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, sqlString)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore)
import Simplex.Messaging.Util
import System.FilePath
import UnliftIO.Directory

View File

@@ -6,8 +6,8 @@ module Simplex.Chat.Bot.KnownContacts where
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Int (Int64)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text as T
import Options.Applicative
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Util (safeDecodeUtf8)

View File

@@ -225,3 +225,4 @@ instance FromField CallState where
fromField = fromTextField_ decodeJSON
$(J.deriveJSON defaultJSON ''RcvCallInvitation)

View File

@@ -1,5 +1,5 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
@@ -41,7 +41,6 @@ import Data.String
import Data.Text (Text)
import Data.Time (NominalDiffTime, UTCTime)
import Data.Version (showVersion)
import Data.Word (Word16)
import Language.Haskell.TH (Exp, Q, runIO)
import Numeric.Natural
import qualified Paths_simplex_chat as SC
@@ -427,19 +426,19 @@ data ChatCommand
| SetGroupTimedMessages GroupName (Maybe Int)
| SetLocalDeviceName Text
| ListRemoteHosts
| StartRemoteHost (Maybe (RemoteHostId, Bool)) (Maybe RCCtrlAddress) (Maybe Word16) -- Start new or known remote host with optional multicast for known host
| SwitchRemoteHost (Maybe RemoteHostId) -- Switch current remote host
| StopRemoteHost RHKey -- Shut down a running session
| DeleteRemoteHost RemoteHostId -- Unregister remote host and remove its data
| StartRemoteHost (Maybe (RemoteHostId, Bool)) -- ^ Start new or known remote host with optional multicast for known host
| SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host
| StopRemoteHost RHKey -- ^ Shut down a running session
| DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data
| StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath}
| GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile}
| ConnectRemoteCtrl RCSignedInvitation -- Connect new or existing controller via OOB data
| FindKnownRemoteCtrl -- Start listening for announcements from all existing controllers
| ConfirmRemoteCtrl RemoteCtrlId -- Confirm the connection with found controller
| VerifyRemoteCtrlSession Text -- Verify remote controller session
| ConnectRemoteCtrl RCSignedInvitation -- ^ Connect new or existing controller via OOB data
| FindKnownRemoteCtrl -- ^ Start listening for announcements from all existing controllers
| ConfirmRemoteCtrl RemoteCtrlId -- ^ Confirm the connection with found controller
| VerifyRemoteCtrlSession Text -- ^ Verify remote controller session
| ListRemoteCtrls
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
| StopRemoteCtrl -- ^ Stop listening for announcements or terminate an active session
| DeleteRemoteCtrl RemoteCtrlId -- ^ Remove all local data associated with a remote controller session
| QuitChat
| ShowVersion
| DebugLocks
@@ -470,7 +469,7 @@ allowRemoteCommand = \case
APIGetNetworkConfig -> False
SetLocalDeviceName _ -> False
ListRemoteHosts -> False
StartRemoteHost {} -> False
StartRemoteHost _ -> False
SwitchRemoteHost {} -> False
StoreRemoteFile {} -> False
GetRemoteFile {} -> False
@@ -557,8 +556,8 @@ data ChatResponse
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection}
| CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection}
| CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan}
| CRSentConfirmation {user :: User, connection :: PendingContactConnection}
| CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRSentConfirmation {user :: User}
| CRSentInvitation {user :: User, customUserProfile :: Maybe Profile}
| CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile}
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
| CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember}
@@ -655,10 +654,11 @@ data ChatResponse
| CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode}
| CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
| CRNewContactConnection {user :: User, connection :: PendingContactConnection}
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
| CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo}
| CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String, localAddrs :: NonEmpty RCCtrlAddress}
| CRRemoteHostStarted {remoteHost_ :: Maybe RemoteHostInfo, invitation :: Text, ctrlPort :: String}
| CRRemoteHostSessionCode {remoteHost_ :: Maybe RemoteHostInfo, sessionCode :: Text}
| CRNewRemoteHost {remoteHost :: RemoteHostInfo}
| CRRemoteHostConnected {remoteHost :: RemoteHostInfo}
@@ -1072,33 +1072,32 @@ throwDBError = throwError . ChatErrorDatabase
-- TODO review errors, some of it can be covered by HTTP2 errors
data RemoteHostError
= RHEMissing -- No remote session matches this identifier
| RHEInactive -- A session exists, but not active
| RHEBusy -- A session is already running
= RHEMissing -- ^ No remote session matches this identifier
| RHEInactive -- ^ A session exists, but not active
| RHEBusy -- ^ A session is already running
| RHETimeout
| RHEBadState -- Illegal state transition
| RHEBadState -- ^ Illegal state transition
| RHEBadVersion {appVersion :: AppVersion}
| RHELocalCommand -- Command not allowed for remote execution
| RHELocalCommand -- ^ Command not allowed for remote execution
| RHEDisconnected {reason :: Text} -- TODO should be sent when disconnected?
| RHEProtocolError RemoteProtocolError
deriving (Show, Exception)
data RemoteHostStopReason
= RHSRConnectionFailed {chatError :: ChatError}
| RHSRCrashed {chatError :: ChatError}
= RHSRConnectionFailed ChatError
| RHSRCrashed ChatError
| RHSRDisconnected
deriving (Show, Exception)
-- TODO review errors, some of it can be covered by HTTP2 errors
data RemoteCtrlError
= RCEInactive -- No session is running
| RCEBadState -- A session is in a wrong state for the current operation
| RCEBusy -- A session is already running
= RCEInactive -- ^ No session is running
| RCEBadState -- ^ A session is in a wrong state for the current operation
| RCEBusy -- ^ A session is already running
| RCETimeout
| RCENoKnownControllers -- No previously-contacted controllers to discover
| RCEBadController -- Attempting to confirm a found controller with another ID
| -- | A session disconnected by a controller
RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text}
| RCENoKnownControllers -- ^ No previously-contacted controllers to discover
| RCEBadController -- ^ Attempting to confirm a found controller with another ID
| RCEDisconnected {remoteCtrlId :: RemoteCtrlId, reason :: Text} -- ^ A session disconnected by a controller
| RCEBadInvitation
| RCEBadVersion {appVersion :: AppVersion}
| RCEHTTP2Error {http2Error :: Text} -- TODO currently not used
@@ -1106,9 +1105,9 @@ data RemoteCtrlError
deriving (Show, Exception)
data RemoteCtrlStopReason
= RCSRDiscoveryFailed {chatError :: ChatError}
| RCSRConnectionFailed {chatError :: ChatError}
| RCSRSetupFailed {chatError :: ChatError}
= RCSRDiscoveryFailed ChatError
| RCSRConnectionFailed ChatError
| RCSRSetupFailed ChatError
| RCSRDisconnected
deriving (Show, Exception)
@@ -1224,8 +1223,8 @@ toView event = do
session <- asks remoteCtrlSession
atomically $
readTVar session >>= \case
Just (_, RCSessionConnected {remoteOutputQ})
| allowRemoteEvent event -> writeTBQueue remoteOutputQ event
Just (_, RCSessionConnected {remoteOutputQ}) | allowRemoteEvent event ->
writeTBQueue remoteOutputQ event
-- TODO potentially, it should hold some events while connecting
_ -> writeTBQueue localQ (Nothing, Nothing, event)

View File

@@ -35,9 +35,9 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController
runSimplexChat ChatOpts {maintenance} u cc chat
| maintenance = wait =<< async (chat u cc)
| otherwise = do
a1 <- runReaderT (startChatController True True True) cc
a2 <- async $ chat u cc
waitEither_ a1 a2
a1 <- runReaderT (startChatController True True True) cc
a2 <- async $ chat u cc
waitEither_ a1 a2
sendChatCmdStr :: ChatController -> String -> IO ChatResponse
sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack s) cc

View File

@@ -6,8 +6,8 @@ module Simplex.Chat.Files where
import Control.Monad.IO.Class
import Simplex.Chat.Controller
import Simplex.Messaging.Util (ifM)
import System.FilePath (combine, splitExtensions)
import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getHomeDirectory, getTemporaryDirectory)
import System.FilePath (splitExtensions, combine)
import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist)
uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath
uniqueCombine fPath fName = tryCombine (0 :: Int)

View File

@@ -19,7 +19,7 @@ import qualified Data.Attoparsec.Text as A
import Data.Char (isDigit, isPunctuation)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.List (foldl', intercalate)
import Data.List (intercalate, foldl')
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe, isNothing)
@@ -85,18 +85,16 @@ newtype FormatColor = FormatColor Color
deriving (Eq, Show)
instance FromJSON FormatColor where
parseJSON =
J.withText "FormatColor" $
fmap FormatColor . \case
"red" -> pure Red
"green" -> pure Green
"blue" -> pure Blue
"yellow" -> pure Yellow
"cyan" -> pure Cyan
"magenta" -> pure Magenta
"black" -> pure Black
"white" -> pure White
unexpected -> fail $ "unexpected FormatColor: " <> show unexpected
parseJSON = J.withText "FormatColor" $ fmap FormatColor . \case
"red" -> pure Red
"green" -> pure Green
"blue" -> pure Blue
"yellow" -> pure Yellow
"cyan" -> pure Cyan
"magenta" -> pure Magenta
"black" -> pure Black
"white" -> pure White
unexpected -> fail $ "unexpected FormatColor: " <> show unexpected
instance ToJSON FormatColor where
toJSON (FormatColor c) = case c of
@@ -169,14 +167,14 @@ markdownP = mconcat <$> A.many' fragmentP
md :: Char -> Format -> Text -> Markdown
md c f s
| T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ c `T.cons` s `T.snoc` c
unmarked $ c `T.cons` s `T.snoc` c
| otherwise = markdown f s
secretP :: Parser Markdown
secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
secret :: Text -> Text -> Text -> Markdown
secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ '#' `T.cons` ss
unmarked $ '#' `T.cons` ss
| otherwise = markdown Secret $ T.init ss
where
ss = b <> s <> a
@@ -217,9 +215,9 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD s
| T.null s = unmarked s
| isUri s =
let t = T.takeWhileEnd isPunctuation s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
in if T.null t then uri else uri :|: unmarked t
let t = T.takeWhileEnd isPunctuation s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
in if T.null t then uri else uri :|: unmarked t
| isEmail s = markdown Email s
| otherwise = unmarked s
uriMarkdown s = case strDecode $ encodeUtf8 s of

View File

@@ -11,6 +11,7 @@
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Messages where
@@ -43,7 +44,7 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptSta
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, enumJSON, sumTypeJSON)
import Simplex.Messaging.Protocol (MsgBody)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
@@ -344,7 +345,7 @@ contactTimedTTL Contact {mergedPreferences = ContactUserPreferences {timedMessag
| forUser enabled && forContact enabled = Just ttl
| otherwise = Nothing
where
TimedMessagesPreference {ttl} = userPreference.preference
TimedMessagesPreference {ttl} = userPreference.preference
groupTimedTTL :: GroupInfo -> Maybe (Maybe Int)
groupTimedTTL GroupInfo {fullGroupPreferences = FullGroupPreferences {timedMessages = TimedMessagesGroupPreference {enable, ttl}}}

View File

@@ -311,7 +311,7 @@ profileToText Profile {displayName, fullName} = displayName <> optionalFullName
msgIntegrityError :: MsgErrorType -> Text
msgIntegrityError = \case
MsgSkipped fromId toId ->
("skipped message ID " <> tshow fromId)
"skipped message ID " <> tshow fromId
<> if fromId == toId then "" else ".." <> tshow toId
MsgBadId msgId -> "unexpected message ID " <> tshow msgId
MsgBadHash -> "incorrect message hash"

View File

@@ -46,9 +46,9 @@ data SndConnEvent
| SCERatchetSync {syncStatus :: RatchetSyncState, member :: Maybe GroupMemberRef}
deriving (Show)
data RcvDirectEvent
= -- RDEProfileChanged {...}
RDEContactDeleted
data RcvDirectEvent =
-- RDEProfileChanged {...}
RDEContactDeleted
deriving (Show)
-- platform-specific JSON encoding (used in API)

View File

@@ -1,22 +0,0 @@
{-# 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;
|]

View File

@@ -537,10 +537,6 @@ CREATE TABLE remote_hosts(
id_key BLOB NOT NULL, -- long-term/identity signing key
host_fingerprint BLOB NOT NULL, -- remote host CA cert fingerprint, set when connected
host_dh_pub BLOB NOT NULL -- last session DH key
,
bind_addr TEXT,
bind_iface TEXT,
bind_port INTEGER
);
CREATE TABLE remote_controllers(
-- e.g., desktops known to a mobile app

View File

@@ -4,12 +4,13 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fobject-code #-}
module Simplex.Chat.Mobile where
import Control.Concurrent.STM
import Control.Exception (SomeException, catch)
import Control.Exception (catch, SomeException)
import Control.Monad.Except
import Control.Monad.Reader
import qualified Data.Aeson as J
@@ -30,7 +31,7 @@ import Foreign.C.Types (CInt (..))
import Foreign.Ptr
import Foreign.StablePtr
import Foreign.Storable (poke)
import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding)
import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList)
@@ -218,7 +219,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do
ExceptT $
(first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations)
`catch` (pure . checkDBError)
`catchAll` (pure . dbError)
`catchAll` (pure . dbError)
where
checkDBError e = case sqlError e of
DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile
@@ -232,7 +233,7 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do
handleErr :: IO () -> IO String
handleErr a = (a $> "") `catch` (pure . show @SomeException)
chatSendCmd :: ChatController -> B.ByteString -> IO JSONByteString
chatSendCmd cc = chatSendRemoteCmd cc Nothing

View File

@@ -6,8 +6,8 @@ import qualified Data.ByteString as B
import Data.ByteString.Internal (ByteString (..), memcpy)
import qualified Data.ByteString.Lazy as LB
import qualified Data.ByteString.Lazy.Internal as LB
import Foreign
import Foreign.C (CInt, CString)
import Foreign
type CJSONString = CString

View File

@@ -1,12 +1,12 @@
{-# LANGUAGE FlexibleContexts #-}
module Simplex.Chat.Mobile.WebRTC
( cChatEncryptMedia,
cChatDecryptMedia,
chatEncryptMedia,
chatDecryptMedia,
reservedSize,
) where
module Simplex.Chat.Mobile.WebRTC (
cChatEncryptMedia,
cChatDecryptMedia,
chatEncryptMedia,
chatDecryptMedia,
reservedSize,
) where
import Control.Monad
import Control.Monad.Except
@@ -21,8 +21,8 @@ import Data.Either (fromLeft)
import Data.Word (Word8)
import Foreign.C (CInt, CString, newCAString)
import Foreign.Ptr (Ptr)
import Simplex.Chat.Mobile.Shared
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Mobile.Shared
cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
cChatEncryptMedia = cTransformMedia chatEncryptMedia

View File

@@ -206,6 +206,7 @@ chatOptsP appDir defaultDbFileName = do
optional $
strOption
( long "device-name"
<> short 'e'
<> metavar "DEVICE"
<> help "Device name to use in connections with remote hosts and controller"
)

View File

@@ -18,10 +18,10 @@ generateRandomProfile = do
pickNoun adjective n
| n == 0 = pick nouns
| otherwise = do
noun <- pick nouns
if noun == adjective
then pickNoun adjective (n - 1)
else pure noun
noun <- pick nouns
if noun == adjective
then pickNoun adjective (n - 1)
else pure noun
adjectives :: [Text]
adjectives =

View File

@@ -13,6 +13,7 @@
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Protocol where

View File

@@ -26,14 +26,13 @@ import qualified Data.ByteString.Base64.URL as B64U
import Data.ByteString.Builder (Builder)
import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import qualified Data.List.NonEmpty as L
import Data.List.NonEmpty (nonEmpty)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Word (Word16, Word32)
import Data.Word (Word32)
import qualified Network.HTTP.Types as N
import Network.HTTP2.Server (responseStreaming)
import qualified Paths_simplex_chat as SC
@@ -98,26 +97,24 @@ discoveryTimeout = 60000000
getRemoteHostClient :: ChatMonad m => RemoteHostId -> m RemoteHostClient
getRemoteHostClient rhId = do
sessions <- asks remoteHostSessions
liftIOEither . atomically $
TM.lookup rhKey sessions >>= \case
Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient
Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
liftIOEither . atomically $ TM.lookup rhKey sessions >>= \case
Just (_, RHSessionConnected {rhClient}) -> pure $ Right rhClient
Just _ -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
where
rhKey = RHId rhId
withRemoteHostSession :: ChatMonad m => RHKey -> SessionSeq -> (RemoteHostSession -> Either ChatError (a, RemoteHostSession)) -> m a
withRemoteHostSession rhKey sseq f = do
sessions <- asks remoteHostSessions
r <-
atomically $
TM.lookup rhKey sessions >>= \case
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
Just (stateSeq, state)
| stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
| otherwise -> case f state of
Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions
Left ce -> pure $ Left ce
r <- atomically $
TM.lookup rhKey sessions >>= \case
Nothing -> pure . Left $ ChatErrorRemoteHost rhKey RHEMissing
Just (stateSeq, state)
| stateSeq /= sseq -> pure . Left $ ChatErrorRemoteHost rhKey RHEBadState
| otherwise -> case f state of
Right (r, newState) -> Right r <$ TM.insert rhKey (sseq, newState) sessions
Left ce -> pure $ Left ce
liftEither r
-- | Transition session state with a 'RHNew' ID to an assigned 'RemoteHostId'
@@ -136,8 +133,8 @@ setNewRemoteHostId sseq rhId = do
where
err = pure . Left . ChatErrorRemoteHost RHNew
startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> Maybe RCCtrlAddress -> Maybe Word16 -> m (NonEmpty RCCtrlAddress, Maybe RemoteHostInfo, RCSignedInvitation)
startRemoteHost rh_ rcAddrPrefs_ port_ = do
startRemoteHost :: ChatMonad m => Maybe (RemoteHostId, Bool) -> m (Maybe RemoteHostInfo, RCSignedInvitation)
startRemoteHost rh_ = do
(rhKey, multicast, remoteHost_, pairing) <- case rh_ of
Just (rhId, multicast) -> do
rh@RemoteHost {hostPairing} <- withStore $ \db -> getRemoteHost db rhId
@@ -145,20 +142,19 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do
Nothing -> (RHNew,False,Nothing,) <$> rcNewHostPairing
sseq <- startRemoteHostSession rhKey
ctrlAppInfo <- mkCtrlAppInfo
(localAddrs, invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast rcAddrPrefs_ port_
let rcAddr_ = L.head localAddrs <$ rcAddrPrefs_
(invitation, rchClient, vars) <- handleConnectError rhKey sseq . withAgent $ \a -> rcConnectHost a pairing (J.toJSON ctrlAppInfo) multicast
cmdOk <- newEmptyTMVarIO
rhsWaitSession <- async $ do
rhKeyVar <- newTVarIO rhKey
atomically $ takeTMVar cmdOk
handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars
handleHostError sseq rhKeyVar $ waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars
let rhs = RHPendingSession {rhKey, rchClient, rhsWaitSession, remoteHost_}
withRemoteHostSession rhKey sseq $ \case
RHSessionStarting ->
let inv = decodeLatin1 $ strEncode invitation
in Right ((), RHSessionConnecting inv rhs)
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState
(localAddrs, remoteHost_, invitation) <$ atomically (putTMVar cmdOk ())
(remoteHost_, invitation) <$ atomically (putTMVar cmdOk ())
where
mkCtrlAppInfo = do
deviceName <- chatReadVar localDeviceName
@@ -171,18 +167,16 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do
when (encoding == PEKotlin && localEncoding == PESwift) $ throwError $ RHEProtocolError RPEIncompatibleEncoding
pure hostInfo
handleConnectError :: ChatMonad m => RHKey -> SessionSeq -> m a -> m a
handleConnectError rhKey sessSeq action =
action `catchChatError` \err -> do
logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err
cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey
throwError err
handleConnectError rhKey sessSeq action = action `catchChatError` \err -> do
logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err
cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey
throwError err
handleHostError :: ChatMonad m => SessionSeq -> TVar RHKey -> m () -> m ()
handleHostError sessSeq rhKeyVar action =
action `catchChatError` \err -> do
logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err
readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err))
waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m ()
waitForHostSession remoteHost_ rhKey sseq rcAddr_ rhKeyVar vars = do
handleHostError sessSeq rhKeyVar action = action `catchChatError` \err -> do
logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err
readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err))
waitForHostSession :: ChatMonad m => Maybe RemoteHostInfo -> RHKey -> SessionSeq -> TVar RHKey -> RCStepTMVar (ByteString, TLS, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> m ()
waitForHostSession remoteHost_ rhKey sseq rhKeyVar vars = do
(sessId, tls, vars') <- timeoutThrow (ChatErrorRemoteHost rhKey RHETimeout) 60000000 $ takeRCStep vars
let sessionCode = verificationCode sessId
withRemoteHostSession rhKey sseq $ \case
@@ -196,7 +190,7 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do
withRemoteHostSession rhKey sseq $ \case
RHSessionPendingConfirmation _ tls' rhs' -> Right ((), RHSessionConfirmed tls' rhs')
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState
rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' rcAddr_ hostDeviceName sseq RHSConfirmed {sessionCode}
rhi@RemoteHostInfo {remoteHostId, storePath} <- upsertRemoteHost pairing' rh_' hostDeviceName sseq RHSConfirmed {sessionCode}
let rhKey' = RHId remoteHostId -- rhKey may be invalid after upserting on RHNew
when (rhKey' /= rhKey) $ do
atomically $ writeTVar rhKeyVar rhKey'
@@ -211,17 +205,17 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do
_ -> Left $ ChatErrorRemoteHost rhKey RHEBadState
chatWriteVar currentRemoteHost $ Just remoteHostId -- this is required for commands to be passed to remote host
toView $ CRRemoteHostConnected rhi {sessionState = Just RHSConnected {sessionCode}}
upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Maybe RCCtrlAddress -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo
upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ rcAddr_ hostDeviceName sseq state = do
upsertRemoteHost :: ChatMonad m => RCHostPairing -> Maybe RemoteHostInfo -> Text -> SessionSeq -> RemoteHostSessionState -> m RemoteHostInfo
upsertRemoteHost pairing'@RCHostPairing {knownHost = kh_} rhi_ hostDeviceName sseq state = do
KnownHostPairing {hostDhPubKey = hostDhPubKey'} <- maybe (throwError . ChatError $ CEInternalError "KnownHost is known after verification") pure kh_
case rhi_ of
Nothing -> do
storePath <- liftIO randomStorePath
rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath rcAddr_ port_ pairing' >>= getRemoteHost db
rh@RemoteHost {remoteHostId} <- withStore $ \db -> insertRemoteHost db hostDeviceName storePath pairing' >>= getRemoteHost db
setNewRemoteHostId sseq remoteHostId
pure $ remoteHostInfo rh $ Just state
Just rhi@RemoteHostInfo {remoteHostId} -> do
withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey' rcAddr_ port_
withStore' $ \db -> updateHostPairing db remoteHostId hostDeviceName hostDhPubKey'
pure (rhi :: RemoteHostInfo) {sessionState = Just state}
onDisconnected :: ChatMonad m => RHKey -> SessionSeq -> m ()
onDisconnected rhKey sseq = do
@@ -256,15 +250,14 @@ cancelRemoteHostSession :: ChatMonad m => Maybe (SessionSeq, RemoteHostStopReaso
cancelRemoteHostSession handlerInfo_ rhKey = do
sessions <- asks remoteHostSessions
crh <- asks currentRemoteHost
deregistered <-
atomically $
TM.lookup rhKey sessions >>= \case
Nothing -> pure Nothing
Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler
Just (_, rhs) -> do
TM.delete rhKey sessions
modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH
pure $ Just rhs
deregistered <- atomically $
TM.lookup rhKey sessions >>= \case
Nothing -> pure Nothing
Just (sessSeq, _) | maybe False (/= sessSeq) (fst <$> handlerInfo_) -> pure Nothing -- ignore cancel from a ghost session handler
Just (_, rhs) -> do
TM.delete rhKey sessions
modifyTVar' crh $ \cur -> if (RHId <$> cur) == Just rhKey then Nothing else cur -- only wipe the closing RH
pure $ Just rhs
forM_ deregistered $ \session -> do
liftIO $ cancelRemoteHost handlingError session `catchAny` (logError . tshow)
forM_ (snd <$> handlerInfo_) $ \rhStopReason ->
@@ -319,8 +312,8 @@ switchRemoteHost rhId_ = do
rhi_ <$ chatWriteVar currentRemoteHost rhId_
remoteHostInfo :: RemoteHost -> Maybe RemoteHostSessionState -> RemoteHostInfo
remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_} sessionState =
RemoteHostInfo {remoteHostId, storePath, hostDeviceName, bindAddress_, bindPort_, sessionState}
remoteHostInfo RemoteHost {remoteHostId, storePath, hostDeviceName} sessionState =
RemoteHostInfo {remoteHostId, storePath, hostDeviceName, sessionState}
deleteRemoteHost :: ChatMonad m => RemoteHostId -> m ()
deleteRemoteHost rhId = do
@@ -408,10 +401,9 @@ findKnownRemoteCtrl = do
(RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <-
timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings
ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing)
rc <-
withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case
Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl"
Just rc -> pure rc
rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case
Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl"
Just rc -> pure rc
atomically $ putTMVar foundCtrl (rc, inv)
let compatible = isJust $ compatibleAppVersion hostAppVersionRange . appVersionRange =<< ctrlAppInfo_
toView CRRemoteCtrlFound {remoteCtrl = remoteCtrlInfo rc (Just RCSSearching), ctrlAppInfo_, appVersion = currentAppVersion, compatible}
@@ -430,7 +422,7 @@ confirmRemoteCtrl rcId = do
pure $ Right (sseq, action, foundCtrl)
_ -> pure . Left $ ChatErrorRemoteCtrl RCEBadState
uninterruptibleCancel listener
(RemoteCtrl {remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found
(RemoteCtrl{remoteCtrlId = foundRcId}, verifiedInv) <- atomically $ takeTMVar found
unless (rcId == foundRcId) $ throwError $ ChatErrorRemoteCtrl RCEBadController
connectRemoteCtrl verifiedInv sseq >>= \case
(Nothing, _) -> throwChatError $ CEInternalError "connecting with a stored ctrl"
@@ -655,12 +647,10 @@ handleCtrlError sseq mkReason name action =
cancelActiveRemoteCtrl :: ChatMonad m => Maybe (SessionSeq, RemoteCtrlStopReason) -> m ()
cancelActiveRemoteCtrl handlerInfo_ = handleAny (logError . tshow) $ do
var <- asks remoteCtrlSession
session_ <-
atomically $
readTVar var >>= \case
Nothing -> pure Nothing
Just (oldSeq, _) | (maybe False ((oldSeq /=) . fst) handlerInfo_) -> pure Nothing
Just (_, s) -> Just s <$ writeTVar var Nothing
session_ <- atomically $ readTVar var >>= \case
Nothing -> pure Nothing
Just (oldSeq, _) | maybe False (/= oldSeq) (fst <$> handlerInfo_) -> pure Nothing
Just (_, s) -> Just s <$ writeTVar var Nothing
forM_ session_ $ \session -> do
liftIO $ cancelRemoteCtrl handlingError session
forM_ (snd <$> handlerInfo_) $ \rcStopReason ->

View File

@@ -11,7 +11,7 @@ module Simplex.Chat.Remote.AppVersion
compatibleAppVersion,
isAppCompatible,
)
where
where
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Aeson as J

View File

@@ -6,8 +6,10 @@ import Network.Socket
#include <HsNet.h>
-- | Toggle multicast group membership.
-- NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups.
{- | Toggle multicast group membership.
NB: Group membership is per-host, not per-process. A socket is only used to access system interface for groups.
-}
setMembership :: Socket -> HostAddress -> Bool -> IO (Either CInt ())
setMembership sock group membership = allocaBytes #{size struct ip_mreq} $ \mReqPtr -> do
#{poke struct ip_mreq, imr_multiaddr} mReqPtr group

View File

@@ -6,8 +6,8 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TupleSections #-}
module Simplex.Chat.Remote.Protocol where
@@ -41,16 +41,16 @@ import Simplex.FileTransfer.Description (FileDigest (..))
import Simplex.Messaging.Agent.Client (agentDRG)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import Simplex.Messaging.Crypto.Lazy (LazyByteString)
import Simplex.Messaging.Crypto.Lazy (LazyByteString)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
import Simplex.Messaging.Transport.Buffer (getBuffered)
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
import Simplex.Messaging.Util (liftEitherError, liftEitherWith, liftError, tshow)
import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode)
import Simplex.RemoteControl.Client (xrcpBlockSize)
import qualified Simplex.RemoteControl.Client as RC
import Simplex.RemoteControl.Types (CtrlSessKeys (..), HostSessKeys (..), RCErrorType (..), SessionCode)
import System.FilePath (takeFileName, (</>))
import UnliftIO
@@ -64,10 +64,10 @@ data RemoteCommand
data RemoteResponse
= RRChatResponse {chatResponse :: ChatResponse}
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- 'Nothing' on poll timeout
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout
| RRFileStored {filePath :: String}
| RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest
| RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- The protocol error happened on the server side
| RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side
deriving (Show)
-- Force platform-independent encoding as the types aren't UI-visible
@@ -126,7 +126,7 @@ remoteStoreFile c localPath fileName = do
r -> badResponse r
remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO ()
remoteGetFile c@RemoteHostClient {encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} =
remoteGetFile c@RemoteHostClient{encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} =
sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case
(getChunk, RRFile {fileSize, fileDigest}) -> do
-- TODO we could optimize by checking size and hash before receiving the file
@@ -140,7 +140,7 @@ sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc
sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do
encFile_ <- mapM (prepareEncryptedFile encryption) file_
encFile_ <- mapM (prepareEncryptedFile encryption) file_
req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd)
HTTP2Response {response, respBody} <- liftEitherError (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing
(header, getNext) <- parseDecryptHTTP2Body encryption response respBody

View File

@@ -5,15 +5,15 @@ module Simplex.Chat.Remote.Transport where
import Control.Monad
import Control.Monad.Except
import Data.ByteString (ByteString)
import Data.ByteString.Builder (Builder, byteString)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Word (Word32)
import Simplex.Chat.Remote.Types
import Simplex.FileTransfer.Description (FileDigest (..))
import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile)
import Simplex.Chat.Remote.Types
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.FileTransfer.Transport (ReceiveFileError (..), receiveSbFile, sendEncFile)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Util (liftEitherError, liftEitherWith)
import Simplex.RemoteControl.Types (RCErrorType (..))

View File

@@ -18,17 +18,16 @@ import qualified Data.Aeson.TH as J
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Word (Word16)
import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Types (verificationCode)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile)
import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Transport (TLS (..))
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
import Simplex.RemoteControl.Client
import Simplex.RemoteControl.Types
import Simplex.Messaging.Crypto.File (CryptoFile)
import Simplex.Messaging.Transport (TLS (..))
data RemoteHostClient = RemoteHostClient
{ hostEncoding :: PlatformEncoding,
@@ -49,13 +48,13 @@ data RemoteCrypto = RemoteCrypto
data RemoteSignatures
= RSSign
{ idPrivKey :: C.PrivateKeyEd25519,
sessPrivKey :: C.PrivateKeyEd25519
}
{ idPrivKey :: C.PrivateKeyEd25519,
sessPrivKey :: C.PrivateKeyEd25519
}
| RSVerify
{ idPubKey :: C.PublicKeyEd25519,
sessPubKey :: C.PublicKeyEd25519
}
{ idPubKey :: C.PublicKeyEd25519,
sessPubKey :: C.PublicKeyEd25519
}
type SessionSeq = Int
@@ -72,12 +71,12 @@ data RemoteHostSession
| RHSessionPendingConfirmation {sessionCode :: Text, tls :: TLS, rhPendingSession :: RHPendingSession}
| RHSessionConfirmed {tls :: TLS, rhPendingSession :: RHPendingSession}
| RHSessionConnected
{ rchClient :: RCHostClient,
tls :: TLS,
rhClient :: RemoteHostClient,
pollAction :: Async (),
storePath :: FilePath
}
{ rchClient :: RCHostClient,
tls :: TLS,
rhClient :: RemoteHostClient,
pollAction :: Async (),
storePath :: FilePath
}
data RemoteHostSessionState
= RHSStarting
@@ -129,8 +128,6 @@ data RemoteHost = RemoteHost
{ remoteHostId :: RemoteHostId,
hostDeviceName :: Text,
storePath :: FilePath,
bindAddress_ :: Maybe RCCtrlAddress,
bindPort_ :: Maybe Word16,
hostPairing :: RCHostPairing
}
@@ -139,8 +136,6 @@ data RemoteHostInfo = RemoteHostInfo
{ remoteHostId :: RemoteHostId,
hostDeviceName :: Text,
storePath :: FilePath,
bindAddress_ :: Maybe RCCtrlAddress,
bindPort_ :: Maybe Word16,
sessionState :: Maybe RemoteHostSessionState
}
deriving (Show)
@@ -163,7 +158,6 @@ data PlatformEncoding
deriving (Show, Eq)
localEncoding :: PlatformEncoding
#if defined(darwin_HOST_OS) && defined(swiftJSON)
localEncoding = PESwift
#else

View File

@@ -4,6 +4,7 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Connections
@@ -24,11 +25,11 @@ import Data.Text (Text)
import Data.Time.Clock (UTCTime (..))
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Messaging.Agent.Protocol (ConnId)
@@ -156,9 +157,8 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity)
getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do
connId_ <-
maybeFirstRow fromOnly $
DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2)
connId_ <- maybeFirstRow fromOnly $
DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_
-- search connection for connection plan:
@@ -167,22 +167,21 @@ getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) =
-- deleted connections are filtered out to allow re-connecting via same contact address
getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity)
getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do
connId_ <-
maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT agent_conn_id FROM (
SELECT
agent_conn_id,
(CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord
FROM connections
WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ?
ORDER BY conn_ord DESC, created_at DESC
LIMIT 1
)
|]
(userId, cReqHash1, cReqHash2, ConnDeleted)
connId_ <- maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT agent_conn_id FROM (
SELECT
agent_conn_id,
(CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord
FROM connections
WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ?
ORDER BY conn_ord DESC, created_at DESC
LIMIT 1
)
|]
(userId, cReqHash1, cReqHash2, ConnDeleted)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_
getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity])

Some files were not shown because too many files have changed in this diff Show More