diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index 3dbcf4700..04eadac2c 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -31,11 +31,11 @@ struct ContentView: View {
@State private var chatListActionSheet: ChatListActionSheet? = nil
private enum ChatListActionSheet: Identifiable {
- case connectViaUrl(action: ConnReqType, link: String)
+ case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
var id: String {
switch self {
- case let .connectViaUrl(_, link): return "connectViaUrl \(link)"
+ case let .planAndConnectSheet(sheet): return sheet.id
}
}
}
@@ -93,7 +93,7 @@ struct ContentView: View {
mainView()
.actionSheet(item: $chatListActionSheet) { sheet in
switch sheet {
- case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link)
+ case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
}
}
} else {
@@ -290,12 +290,19 @@ struct ContentView: View {
if let url = m.appOpenUrl {
m.appOpenUrl = nil
var path = url.path
- logger.debug("ContentView.connectViaUrl path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
- let action: ConnReqType = path == "contact" ? .contact : .invitation
- let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
- chatListActionSheet = .connectViaUrl(action: action, link: link)
+ // TODO normalize in backend; revert
+ // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
+ var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
+ link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link
+ planAndConnect(
+ link,
+ showAlert: showPlanAndConnectAlert,
+ showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
+ dismiss: false,
+ incognito: nil
+ )
} else {
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
}
@@ -303,20 +310,8 @@ struct ContentView: View {
}
}
- private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet {
- let title: LocalizedStringKey
- switch action {
- case .contact: title = "Connect via contact link"
- case .invitation: title = "Connect via one-time link"
- }
- return ActionSheet(
- title: Text(title),
- buttons: [
- .default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
- .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
- .cancel()
- ]
- )
+ private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
+ AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
}
}
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index a1ec2f8f5..b1a83dd5e 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -152,6 +152,16 @@ final class ChatModel: ObservableObject {
}
}
+ func getGroupChat(_ groupId: Int64) -> Chat? {
+ chats.first { chat in
+ if case let .group(groupInfo) = chat.chatInfo {
+ return groupInfo.groupId == groupId
+ } else {
+ return false
+ }
+ }
+ }
+
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
@@ -688,41 +698,3 @@ final class Chat: ObservableObject, Identifiable {
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
-
-enum NetworkStatus: Decodable, Equatable {
- case unknown
- case connected
- case disconnected
- case error(String)
-
- var statusString: LocalizedStringKey {
- get {
- switch self {
- case .connected: return "connected"
- case .error: return "error"
- default: return "connecting"
- }
- }
- }
-
- var statusExplanation: LocalizedStringKey {
- get {
- switch self {
- case .connected: return "You are connected to the server used to receive messages from this contact."
- case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
- default: return "Trying to connect to the server used to receive messages from this contact."
- }
- }
- }
-
- var imageName: String {
- get {
- switch self {
- case .unknown: return "circle.dotted"
- case .connected: return "circle.fill"
- case .disconnected: return "ellipsis.circle.fill"
- case .error: return "exclamationmark.circle.fill"
- }
- }
- }
-}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 85e66e893..91c5b1b36 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -257,6 +257,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r
}
+func apiSetEncryptLocalFiles(_ enable: Boolean) throws {
+ let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
+ if case .cmdOk = r { return }
+ throw r
+}
+
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
@@ -586,6 +592,15 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r
}
+func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
+ logger.error("apiConnectPlan connReq: \(connReq)")
+ let userId = try currentUserId("apiConnectPlan")
+ let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
+ if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan }
+ logger.error("apiConnectPlan error: \(responseError(r))")
+ throw r
+}
+
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert {
@@ -610,10 +625,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id }
}
- let alert = mkAlert(
- title: "Contact already exists",
- message: "You are already connected to \(contact.displayName)."
- )
+ let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
case .chatCmdError(_, .error(.invalidConnReq)):
let alert = mkAlert(
@@ -641,6 +653,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
return (nil, alert)
}
+func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
+ mkAlert(
+ title: "Contact already exists",
+ message: "You are already connected to \(contact.displayName)."
+ )
+}
+
private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert
@@ -944,6 +963,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
}
}
+func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
+ let r = chatSendCmdSync(.apiGetNetworkStatuses)
+ if case let .networkStatuses(_, statuses) = r { return statuses }
+ throw r
+}
+
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
do {
if chat.chatStats.unreadCount > 0 {
@@ -1133,6 +1158,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(getXFTPCfg())
+ // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
@@ -1348,13 +1374,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await updateContactsStatus(contactRefs, status: .connected)
case let .contactsDisconnected(_, contactRefs):
await updateContactsStatus(contactRefs, status: .disconnected)
- case let .contactSubError(user, contact, chatError):
- await MainActor.run {
- if active(user) {
- m.updateContact(contact)
- }
- processContactSubError(contact, chatError)
- }
case let .contactSubSummary(_, contactSubscriptions):
await MainActor.run {
for sub in contactSubscriptions {
@@ -1369,6 +1388,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
+ case let .networkStatus(status, connections):
+ await MainActor.run {
+ for cId in connections {
+ m.networkStatuses[cId] = status
+ }
+ }
+ case let .networkStatuses(_, statuses): ()
+ await MainActor.run {
+ for s in statuses {
+ m.networkStatuses[s.agentConnId] = s.networkStatus
+ }
+ }
case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
@@ -1649,7 +1680,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}
- m.setContactNetworkStatus(contact, .error(err))
+ m.setContactNetworkStatus(contact, .error(connectionError: err))
}
func refreshCallInvitations() throws {
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 389080efc..81473709c 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -965,7 +965,7 @@ struct ChatView: View {
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
- chatSettings.enableNtfs = enableNtfs
+ chatSettings.enableNtfs = enableNtfs ? .all : .none
updateChatSettings(chat, chatSettings: chatSettings)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index 5ec14f5be..3d10101de 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -19,7 +19,7 @@ struct GroupMemberInfoView: View {
@State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
- @State private var connectToMemberDialog: Bool = false
+ @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true
@State private var progressIndicator = false
@@ -30,9 +30,8 @@ struct GroupMemberInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
- case connRequestSentAlert(type: ConnReqType)
+ case planAndConnectAlert(alert: PlanAndConnectAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
- case other(alert: Alert)
var id: String {
switch self {
@@ -41,9 +40,8 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
- case .connRequestSentAlert: return "connRequestSentAlert"
+ case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .error(title, _): return "error \(title)"
- case let .other(alert): return "other \(alert)"
}
}
}
@@ -206,11 +204,11 @@ struct GroupMemberInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
- case let .connRequestSentAlert(type): return connReqSentAlert(type)
+ case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
- case let .other(alert): return alert
}
}
+ .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
if progressIndicator {
ProgressView().scaleEffect(2)
@@ -220,25 +218,16 @@ struct GroupMemberInfoView: View {
func connectViaAddressButton(_ contactLink: String) -> some View {
Button {
- connectToMemberDialog = true
+ planAndConnect(
+ contactLink,
+ showAlert: { alert = .planAndConnectAlert(alert: $0) },
+ showActionSheet: { sheet = $0 },
+ dismiss: true,
+ incognito: nil
+ )
} label: {
Label("Connect", systemImage: "link")
}
- .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
- Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
- Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
- }
- }
-
- func connectViaAddress(incognito: Bool, contactLink: String) {
- Task {
- let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
- if let connReqType = connReqType {
- alert = .connRequestSentAlert(type: connReqType)
- } else if let connectAlert = connectAlert {
- alert = .other(alert: connectAlert)
- }
- }
}
func knownDirectChatButton(_ chat: Chat) -> some View {
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 2eb6d9f6b..b7b7e73dc 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -111,14 +111,17 @@ struct ChatPreviewView: View {
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
- text
+ let t = text
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
- .privacySensitive(!showChatPreviews && !draft)
- .redacted(reason: .privacy)
+ if !showChatPreviews && !draft {
+ t.privacySensitive(true).redacted(reason: .privacy)
+ } else {
+ t
+ }
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
index a727ad6be..e4fbd8bd4 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
@@ -58,65 +58,331 @@ struct NewChatButton: View {
}
}
-enum ConnReqType: Equatable {
- case contact
- case invitation
+enum PlanAndConnectAlert: Identifiable {
+ case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
+ case invitationLinkConnecting(connectionLink: String)
+ case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
+ case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
+ case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
+
+ var id: String {
+ switch self {
+ case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
+ case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
+ case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
+ case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
+ case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
+ }
+ }
}
-func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) {
- Task {
- if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
- DispatchQueue.main.async {
- dismiss?()
- AlertManager.shared.showAlert(connReqSentAlert(connReqType))
- }
+func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
+ switch alert {
+ case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
+ return Alert(
+ title: Text("Connect to yourself?"),
+ message: Text("This is your own one-time link!"),
+ primaryButton: .destructive(
+ Text(incognito ? "Connect incognito" : "Connect"),
+ action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
+ ),
+ secondaryButton: .cancel()
+ )
+ case .invitationLinkConnecting:
+ return Alert(
+ title: Text("Already connecting!"),
+ message: Text("You are already connecting via this one-time link!")
+ )
+ case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
+ return Alert(
+ title: Text("Connect to yourself?"),
+ message: Text("This is your own SimpleX address!"),
+ primaryButton: .destructive(
+ Text(incognito ? "Connect incognito" : "Connect"),
+ action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
+ ),
+ secondaryButton: .cancel()
+ )
+ case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
+ return Alert(
+ title: Text("Join group?"),
+ message: Text("You will connect to all group members."),
+ primaryButton: .default(
+ Text(incognito ? "Join incognito" : "Join"),
+ action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
+ ),
+ secondaryButton: .cancel()
+ )
+ case let .groupLinkConnecting(_, groupInfo):
+ if let groupInfo = groupInfo {
+ return Alert(
+ title: Text("Group already exists!"),
+ message: Text("You are already joining the group \(groupInfo.displayName).")
+ )
} else {
- DispatchQueue.main.async {
- dismiss?()
+ return Alert(
+ title: Text("Already joining the group!"),
+ message: Text("You are already joining the group via this link.")
+ )
+ }
+ }
+}
+
+enum PlanAndConnectActionSheet: Identifiable {
+ case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
+ case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
+ case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
+
+ var id: String {
+ switch self {
+ case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
+ case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)"
+ case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
+ }
+ }
+}
+
+func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
+ switch sheet {
+ case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
+ return ActionSheet(
+ title: Text(title),
+ buttons: [
+ .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
+ .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
+ .cancel()
+ ]
+ )
+ case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
+ return ActionSheet(
+ title: Text(title),
+ buttons: [
+ .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
+ .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
+ .cancel()
+ ]
+ )
+ case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
+ if let incognito = incognito {
+ return ActionSheet(
+ title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
+ buttons: [
+ .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
+ .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
+ .cancel()
+ ]
+ )
+ } else {
+ return ActionSheet(
+ title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
+ buttons: [
+ .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
+ .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
+ .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
+ .cancel()
+ ]
+ )
+ }
+ }
+}
+
+func planAndConnect(
+ _ connectionLink: String,
+ showAlert: @escaping (PlanAndConnectAlert) -> Void,
+ showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
+ dismiss: Bool,
+ incognito: Bool?
+) {
+ Task {
+ do {
+ let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
+ switch connectionPlan {
+ case let .invitationLink(ilp):
+ switch ilp {
+ case .ok:
+ logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
+ if let incognito = incognito {
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
+ } else {
+ showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
+ }
+ case .ownLink:
+ logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
+ if let incognito = incognito {
+ showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
+ } else {
+ showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
+ }
+ case let .connecting(contact_):
+ logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
+ if let contact = contact_ {
+ openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
+ } else {
+ showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
+ }
+ case let .known(contact):
+ logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
+ openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
+ }
+ case let .contactAddress(cap):
+ switch cap {
+ case .ok:
+ logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
+ if let incognito = incognito {
+ connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
+ } else {
+ showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
+ }
+ case .ownLink:
+ logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
+ if let incognito = incognito {
+ showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
+ } else {
+ showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
+ }
+ case let .connecting(contact):
+ logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")")
+ openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
+ case let .known(contact):
+ logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
+ openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
+ }
+ case let .groupLink(glp):
+ switch glp {
+ case .ok:
+ if let incognito = incognito {
+ showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
+ } else {
+ showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
+ }
+ case let .ownLink(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
+ showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
+ case let .connecting(groupInfo_):
+ logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")")
+ showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
+ case let .known(groupInfo):
+ logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
+ openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
+ }
+ }
+ } catch {
+ logger.debug("planAndConnect, plan error")
+ if let incognito = incognito {
+ connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
+ } else {
+ showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
}
}
}
}
-struct CReqClientData: Decodable {
- var type: String
- var groupLinkId: String?
-}
-
-func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? {
- if let hashIndex = connectionLink.firstIndex(of: "#"),
- let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])),
- let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false),
- let data = components.queryItems?.first(where: { $0.name == "data" })?.value,
- let d = data.data(using: .utf8),
- let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) {
- return crData
- } else {
- return nil
+private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
+ Task {
+ if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
+ let crt: ConnReqType
+ if let plan = connectionPlan {
+ crt = planToConnReqType(plan)
+ } else {
+ crt = connReqType
+ }
+ DispatchQueue.main.async {
+ if dismiss {
+ dismissAllSheets(animated: true) {
+ AlertManager.shared.showAlert(connReqSentAlert(crt))
+ }
+ } else {
+ AlertManager.shared.showAlert(connReqSentAlert(crt))
+ }
+ }
+ } else {
+ if dismiss {
+ DispatchQueue.main.async {
+ dismissAllSheets(animated: true)
+ }
+ }
+ }
}
}
-func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
- return crData.type == "group" && crData.groupLinkId != nil
+func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
+ Task {
+ let m = ChatModel.shared
+ if let c = m.getContactChat(contact.contactId) {
+ DispatchQueue.main.async {
+ if dismiss {
+ dismissAllSheets(animated: true) {
+ m.chatId = c.id
+ showAlreadyExistsAlert?()
+ }
+ } else {
+ m.chatId = c.id
+ showAlreadyExistsAlert?()
+ }
+ }
+ }
+ }
}
-func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert {
- return Alert(
- title: Text("Connect via group link?"),
- message: Text("You will join a group this link refers to and connect to its group members."),
- primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) {
- connectViaLink(connectionLink, incognito: incognito)
- },
- secondaryButton: .cancel()
+func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
+ Task {
+ let m = ChatModel.shared
+ if let g = m.getGroupChat(groupInfo.groupId) {
+ DispatchQueue.main.async {
+ if dismiss {
+ dismissAllSheets(animated: true) {
+ m.chatId = g.id
+ showAlreadyExistsAlert?()
+ }
+ } else {
+ m.chatId = g.id
+ showAlreadyExistsAlert?()
+ }
+ }
+ }
+ }
+}
+
+func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
+ mkAlert(
+ title: "Contact already exists",
+ message: "You are already connecting to \(contact.displayName)."
)
}
+func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
+ mkAlert(
+ title: "Group already exists",
+ message: "You are already in group \(groupInfo.displayName)."
+ )
+}
+
+enum ConnReqType: Equatable {
+ case invitation
+ case contact
+ case groupLink
+
+ var connReqSentText: LocalizedStringKey {
+ switch self {
+ case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
+ case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
+ case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
+ }
+ }
+}
+
+private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
+ switch connectionPlan {
+ case .invitationLink: return .invitation
+ case .contactAddress: return .contact
+ case .groupLink: return .groupLink
+ }
+}
+
func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert(
title: "Connection request sent!",
- message: type == .contact
- ? "You will be connected when your connection request is accepted, please wait or check later!"
- : "You will be connected when your contact's device is online, please wait or check later!"
+ message: type.connReqSentText
)
}
diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
index ec199327c..af84f19fe 100644
--- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
+++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
@@ -14,6 +14,8 @@ struct PasteToConnectView: View {
@State private var connectionLink: String = ""
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@FocusState private var linkEditorFocused: Bool
+ @State private var alert: PlanAndConnectAlert?
+ @State private var sheet: PlanAndConnectActionSheet?
var body: some View {
List {
@@ -57,6 +59,8 @@ struct PasteToConnectView: View {
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
}
+ .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
+ .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
}
private func linkEditor() -> some View {
@@ -83,13 +87,13 @@ struct PasteToConnectView: View {
private func connect() {
let link = connectionLink.trimmingCharacters(in: .whitespaces)
- if let crData = parseLinkQueryData(link),
- checkCRDataGroup(crData) {
- dismiss()
- AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault))
- } else {
- connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault)
- }
+ planAndConnect(
+ link,
+ showAlert: { alert = $0 },
+ showActionSheet: { sheet = $0 },
+ dismiss: true,
+ incognito: incognitoDefault
+ )
}
}
diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
index 01c8a4659..c55ba1502 100644
--- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
+++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
@@ -13,6 +13,8 @@ import CodeScanner
struct ScanToConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+ @State private var alert: PlanAndConnectAlert?
+ @State private var sheet: PlanAndConnectActionSheet?
var body: some View {
ScrollView {
@@ -49,18 +51,20 @@ struct ScanToConnectView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(Color(.systemGroupedBackground))
+ .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
+ .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
}
func processQRCode(_ resp: Result) {
switch resp {
case let .success(r):
- if let crData = parseLinkQueryData(r.string),
- checkCRDataGroup(crData) {
- dismiss()
- AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault))
- } else {
- Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) }
- }
+ planAndConnect(
+ r.string,
+ showAlert: { alert = $0 },
+ showActionSheet: { sheet = $0 },
+ dismiss: true,
+ incognito: incognitoDefault
+ )
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift
index 645fdb595..2fb069a86 100644
--- a/apps/ios/SimpleX NSE/NotificationService.swift
+++ b/apps/ios/SimpleX NSE/NotificationService.swift
@@ -216,6 +216,7 @@ func startChat() -> DBMigrationResult? {
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(xftpConfig)
+ // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
let justStarted = try apiStartChat()
chatStarted = true
if justStarted {
@@ -351,6 +352,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r
}
+func apiSetEncryptLocalFiles(_ enable: Boolean) throws {
+ let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
+ if case .cmdOk = r { return }
+ throw r
+}
+
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 18021e307..2a90e75d8 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -85,6 +85,11 @@
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; };
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; };
5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; };
+ 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0112AD746C8001FD661 /* libgmpxx.a */; };
+ 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0122AD746C8001FD661 /* libffi.a */; };
+ 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; };
+ 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; };
+ 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0152AD746C8001FD661 /* libgmp.a */; };
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; };
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; };
5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; };
@@ -114,11 +119,6 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; };
- 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739922AD44E2E009470A9 /* libgmp.a */; };
- 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */; };
- 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739942AD44E2E009470A9 /* libffi.a */; };
- 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */; };
- 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739962AD44E2E009470A9 /* libgmpxx.a */; };
5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; };
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
@@ -176,6 +176,11 @@
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
+ 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; };
+ 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; };
+ 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; };
+ 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; };
+ 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
@@ -358,6 +363,11 @@
5CA85D0A297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; };
5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; };
5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; };
+ 5CA8D0112AD746C8001FD661 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
+ 5CA8D0122AD746C8001FD661 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; };
+ 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; };
+ 5CA8D0152AD746C8001FD661 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
5CAB912529E93F9400F34A95 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; };
5CAC41182A192D8400C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; };
5CAC411A2A192DE800C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = "ja.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; };
@@ -395,11 +405,6 @@
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; };
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; };
- 5CC739922AD44E2E009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
- 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a"; sourceTree = ""; };
- 5CC739942AD44E2E009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a"; sourceTree = ""; };
- 5CC739962AD44E2E009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; };
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; };
@@ -458,6 +463,11 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; };
+ 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
+ 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; };
+ 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; };
+ 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
+ 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; };
@@ -507,13 +517,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */,
+ 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */,
+ 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */,
+ 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
- 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */,
- 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */,
- 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */,
+ 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
- 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */,
+ 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -574,11 +584,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
- 5CC739942AD44E2E009470A9 /* libffi.a */,
- 5CC739922AD44E2E009470A9 /* libgmp.a */,
- 5CC739962AD44E2E009470A9 /* libgmpxx.a */,
- 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */,
- 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */,
+ 64AB9C812AD6B6B900B21C4C /* libffi.a */,
+ 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */,
+ 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */,
+ 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */,
+ 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */,
);
path = Libraries;
sourceTree = "";
diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift
index 951f726be..ddab4c73b 100644
--- a/apps/ios/SimpleXChat/APITypes.swift
+++ b/apps/ios/SimpleXChat/APITypes.swift
@@ -32,6 +32,7 @@ public enum ChatCommand {
case setTempFolder(tempFolder: String)
case setFilesFolder(filesFolder: String)
case apiSetXFTPConfig(config: XFTPFileConfig?)
+ case apiSetEncryptLocalFiles(enable: Bool)
case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage
@@ -86,6 +87,7 @@ public enum ChatCommand {
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
+ case apiConnectPlan(userId: Int64, connReq: String)
case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case apiClearChat(type: ChatType, id: Int64)
@@ -110,10 +112,11 @@ public enum ChatCommand {
case apiEndCall(contact: Contact)
case apiGetCallInvitations
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
+ case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
- case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?)
- case setFileToReceive(fileId: Int64, encrypted: Bool)
+ case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
+ case setFileToReceive(fileId: Int64, encrypted: Bool?)
case cancelFile(fileId: Int64)
case showVersion
case string(String)
@@ -150,6 +153,7 @@ public enum ChatCommand {
} else {
return "/_xftp off"
}
+ case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete"
@@ -218,6 +222,7 @@ public enum ChatCommand {
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
+ case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
@@ -241,15 +246,11 @@ public enum ChatCommand {
case let .apiEndCall(contact): return "/_call end @\(contact.apiId)"
case .apiGetCallInvitations: return "/_call get"
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
+ case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
- case let .receiveFile(fileId, encrypted, inline):
- let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))"
- if let inline = inline {
- return s + " inline=\(onOff(inline))"
- }
- return s
- case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))"
+ case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
+ case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
case let .cancelFile(fileId): return "/fcancel \(fileId)"
case .showVersion: return "/version"
case let .string(str): return str
@@ -279,6 +280,7 @@ public enum ChatCommand {
case .setTempFolder: return "setTempFolder"
case .setFilesFolder: return "setFilesFolder"
case .apiSetXFTPConfig: return "apiSetXFTPConfig"
+ case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage"
@@ -333,6 +335,7 @@ public enum ChatCommand {
case .apiVerifyGroupMember: return "apiVerifyGroupMember"
case .apiAddContact: return "apiAddContact"
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
+ case .apiConnectPlan: return "apiConnectPlan"
case .apiConnect: return "apiConnect"
case .apiDeleteChat: return "apiDeleteChat"
case .apiClearChat: return "apiClearChat"
@@ -356,6 +359,7 @@ public enum ChatCommand {
case .apiEndCall: return "apiEndCall"
case .apiGetCallInvitations: return "apiGetCallInvitations"
case .apiCallStatus: return "apiCallStatus"
+ case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
case .apiChatRead: return "apiChatRead"
case .apiChatUnread: return "apiChatUnread"
case .receiveFile: return "receiveFile"
@@ -414,6 +418,13 @@ public enum ChatCommand {
b ? "on" : "off"
}
+ private func onOffParam(_ param: String, _ b: Bool?) -> String {
+ if let b = b {
+ return " \(param)=\(onOff(b))"
+ }
+ return ""
+ }
+
private func maybePwd(_ pwd: String?) -> String {
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
}
@@ -457,6 +468,7 @@ public enum ChatResponse: Decodable, Error {
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
+ case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef)
case sentInvitation(user: UserRef)
case contactAlreadyExists(user: UserRef, contact: Contact)
@@ -480,11 +492,14 @@ public enum ChatResponse: Decodable, Error {
case acceptingContactRequest(user: UserRef, contact: Contact)
case contactRequestRejected(user: UserRef)
case contactUpdated(user: UserRef, toContact: Contact)
+ // TODO remove events below
case contactsSubscribed(server: String, contactRefs: [ContactRef])
case contactsDisconnected(server: String, contactRefs: [ContactRef])
- case contactSubError(user: UserRef, contact: Contact, chatError: ChatError)
case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus])
- case groupSubscribed(user: UserRef, groupInfo: GroupInfo)
+ // TODO remove events above
+ case networkStatus(networkStatus: NetworkStatus, connections: [String])
+ case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
+ case groupSubscribed(user: UserRef, groupInfo: GroupRef)
case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError])
case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed
@@ -595,6 +610,7 @@ public enum ChatResponse: Decodable, Error {
case .connectionVerified: return "connectionVerified"
case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
+ case .connectionPlan: return "connectionPlan"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
case .contactAlreadyExists: return "contactAlreadyExists"
@@ -620,8 +636,9 @@ public enum ChatResponse: Decodable, Error {
case .contactUpdated: return "contactUpdated"
case .contactsSubscribed: return "contactsSubscribed"
case .contactsDisconnected: return "contactsDisconnected"
- case .contactSubError: return "contactSubError"
case .contactSubSummary: return "contactSubSummary"
+ case .networkStatus: return "networkStatus"
+ case .networkStatuses: return "networkStatuses"
case .groupSubscribed: return "groupSubscribed"
case .memberSubErrors: return "memberSubErrors"
case .groupEmpty: return "groupEmpty"
@@ -732,6 +749,7 @@ public enum ChatResponse: Decodable, Error {
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
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 .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
@@ -757,8 +775,9 @@ public enum ChatResponse: Decodable, Error {
case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
- case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))")
case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions))
+ case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
+ case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors))
case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo))
@@ -851,6 +870,33 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
}
}
+public enum ConnectionPlan: Decodable {
+ case invitationLink(invitationLinkPlan: InvitationLinkPlan)
+ case contactAddress(contactAddressPlan: ContactAddressPlan)
+ case groupLink(groupLinkPlan: GroupLinkPlan)
+}
+
+public enum InvitationLinkPlan: Decodable {
+ case ok
+ case ownLink
+ case connecting(contact_: Contact?)
+ case known(contact: Contact)
+}
+
+public enum ContactAddressPlan: Decodable {
+ case ok
+ case ownLink
+ case connecting(contact: Contact)
+ case known(contact: Contact)
+}
+
+public enum GroupLinkPlan: Decodable {
+ case ok
+ case ownLink(groupInfo: GroupInfo)
+ case connecting(groupInfo_: GroupInfo?)
+ case known(groupInfo: GroupInfo)
+}
+
struct NewUser: Encodable {
var profile: Profile?
var sameServers: Bool
@@ -1181,18 +1227,67 @@ public struct KeepAliveOpts: Codable, Equatable {
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
}
+public enum NetworkStatus: Decodable, Equatable {
+ case unknown
+ case connected
+ case disconnected
+ case error(connectionError: String)
+
+ public var statusString: LocalizedStringKey {
+ get {
+ switch self {
+ case .connected: return "connected"
+ case .error: return "error"
+ default: return "connecting"
+ }
+ }
+ }
+
+ public var statusExplanation: LocalizedStringKey {
+ get {
+ switch self {
+ case .connected: return "You are connected to the server used to receive messages from this contact."
+ case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
+ default: return "Trying to connect to the server used to receive messages from this contact."
+ }
+ }
+ }
+
+ public var imageName: String {
+ get {
+ switch self {
+ case .unknown: return "circle.dotted"
+ case .connected: return "circle.fill"
+ case .disconnected: return "ellipsis.circle.fill"
+ case .error: return "exclamationmark.circle.fill"
+ }
+ }
+ }
+}
+
+public struct ConnNetworkStatus: Decodable {
+ public var agentConnId: String
+ public var networkStatus: NetworkStatus
+}
+
public struct ChatSettings: Codable {
- public var enableNtfs: Bool
+ public var enableNtfs: MsgFilter
public var sendRcpts: Bool?
public var favorite: Bool
- public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) {
+ public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) {
self.enableNtfs = enableNtfs
self.sendRcpts = sendRcpts
self.favorite = favorite
}
- public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false)
+ public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
+}
+
+public enum MsgFilter: String, Codable {
+ case none
+ case all
+ case mentions
}
public struct UserMsgReceiptSettings: Codable {
@@ -1420,6 +1515,7 @@ public enum ChatErrorType: Decodable {
case chatNotStarted
case chatNotStopped
case chatStoreChanged
+ case connectionPlan(connectionPlan: ConnectionPlan)
case invalidConnReq
case invalidChatMessage(connection: Connection, message: String)
case contactNotReady(contact: Contact)
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index f9996d840..caaccb545 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
public var ntfsEnabled: Bool {
- self.chatSettings?.enableNtfs ?? false
+ self.chatSettings?.enableNtfs == .all
}
public var chatSettings: ChatSettings? {
@@ -1729,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
)
}
+public struct GroupRef: Decodable {
+ public var groupId: Int64
+ var localDisplayName: GroupName
+}
+
public struct GroupProfile: Codable, NamedChat {
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
self.displayName = displayName
@@ -1758,6 +1763,7 @@ public struct GroupMember: Identifiable, Decodable {
public var memberRole: GroupMemberRole
public var memberCategory: GroupMemberCategory
public var memberStatus: GroupMemberStatus
+ public var memberSettings: GroupMemberSettings
public var invitedBy: InvitedBy
public var localDisplayName: ContactName
public var memberProfile: LocalProfile
@@ -1851,6 +1857,7 @@ public struct GroupMember: Identifiable, Decodable {
memberRole: .admin,
memberCategory: .inviteeMember,
memberStatus: .memComplete,
+ memberSettings: GroupMemberSettings(showMessages: true),
invitedBy: .user,
localDisplayName: "alice",
memberProfile: LocalProfile.sampleData,
@@ -1860,11 +1867,20 @@ public struct GroupMember: Identifiable, Decodable {
)
}
+public struct GroupMemberSettings: Decodable {
+ var showMessages: Bool
+}
+
public struct GroupMemberRef: Decodable {
var groupMemberId: Int64
var profile: Profile
}
+public struct GroupMemberIds: Decodable {
+ var groupMemberId: Int64
+ var groupId: Int64
+}
+
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
case observer = "observer"
case member = "member"
@@ -1957,7 +1973,7 @@ public enum InvitedBy: Decodable {
}
public struct MemberSubError: Decodable {
- var member: GroupMember
+ var member: GroupMemberIds
var memberError: ChatError
}
@@ -1983,8 +1999,8 @@ public enum ConnectionEntity: Decodable {
public var ntfsEnabled: Bool {
switch self {
- case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false
- case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs
+ case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all
+ case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
case .sndFileConnection: return false
case .rcvFileConnection: return false
case let .userContactConnection(userContact): return userContact.groupId == nil
diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
index f70032788..d2c446517 100644
--- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
+++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt
@@ -9,12 +9,14 @@ import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
+import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.RcvCallInvitation
import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.withLock
import java.io.*
import java.util.*
import java.util.concurrent.TimeUnit
@@ -52,21 +54,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
- kotlin.runCatching {
- val currentUserId = chatModel.currentUser.value?.userId
- val chats = ArrayList(chatController.apiGetChats())
- /** Active user can be changed in background while [ChatController.apiGetChats] is executing */
- if (chatModel.currentUser.value?.userId == currentUserId) {
- val currentChatId = chatModel.chatId.value
- val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
- if (oldStats != null) {
- val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
- /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
- if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
+ updatingChatsMutex.withLock {
+ kotlin.runCatching {
+ val currentUserId = chatModel.currentUser.value?.userId
+ val chats = ArrayList(chatController.apiGetChats())
+ /** Active user can be changed in background while [ChatController.apiGetChats] is executing */
+ if (chatModel.currentUser.value?.userId == currentUserId) {
+ val currentChatId = chatModel.chatId.value
+ val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
+ if (oldStats != null) {
+ val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
+ /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
+ if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
+ }
+ chatModel.updateChats(chats)
}
- chatModel.updateChats(chats)
- }
- }.onFailure { Log.e(TAG, it.stackTraceToString()) }
+ }.onFailure { Log.e(TAG, it.stackTraceToString()) }
+ }
}
}
Lifecycle.Event.ON_RESUME -> {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index 8687ac390..a4b76cc79 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -6,17 +6,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
-import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
-import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
import kotlinx.serialization.*
@@ -103,6 +103,8 @@ object ChatModel {
val filesToDelete = mutableSetOf()
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
+ var updatingChatsMutex: Mutex = Mutex()
+
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
} else {
@@ -199,7 +201,7 @@ object ChatModel {
}
}
- suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
+ suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -222,10 +224,11 @@ object ChatModel {
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
- // add to current chat
- if (chatId.value == cInfo.id) {
- Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
- withContext(Dispatchers.Main) {
+ Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
+ withContext(Dispatchers.Main) {
+ // add to current chat
+ if (chatId.value == cInfo.id) {
+ Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}")
// Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
@@ -239,7 +242,7 @@ object ChatModel {
}
}
- suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
+ suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
@@ -259,10 +262,10 @@ object ChatModel {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true
}
- // update current chat
- return if (chatId.value == cInfo.id) {
- Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
- withContext(Dispatchers.Main) {
+ Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
+ return withContext(Dispatchers.Main) {
+ // update current chat
+ if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
@@ -273,15 +276,15 @@ object ChatModel {
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
true
}
+ } else {
+ res
}
- } else {
- res
}
}
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) {
- if (chatId.value == cInfo.id) {
- withContext(Dispatchers.Main) {
+ withContext(Dispatchers.Main) {
+ if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
@@ -726,7 +729,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
- override val ntfsEnabled get() = contactConnection.incognito
+ override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
@@ -786,16 +789,19 @@ sealed class NetworkStatus {
val statusExplanation: String get() =
when (this) {
is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact)
- is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error)
+ is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError)
else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages)
}
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@Serializable @SerialName("connected") class Connected: NetworkStatus()
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
- @Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
+ @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus()
}
+@Serializable
+data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus)
+
@Serializable
data class Contact(
val contactId: Long,
@@ -822,7 +828,7 @@ data class Contact(
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|| nextSendGrpInv
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
- override val ntfsEnabled get() = chatSettings.enableNtfs
+ override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
@@ -869,7 +875,7 @@ data class Contact(
activeConn = Connection.sampleData,
contactUsed = true,
contactStatus = ContactStatus.Active,
- chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
+ chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(),
@@ -1009,7 +1015,7 @@ data class GroupInfo (
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val sendMsgEnabled get() = membership.memberActive
- override val ntfsEnabled get() = chatSettings.enableNtfs
+ override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
@@ -1041,13 +1047,16 @@ data class GroupInfo (
fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null,
- chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
+ chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
}
}
+@Serializable
+data class GroupRef(val groupId: Long, val localDisplayName: String)
+
@Serializable
data class GroupProfile (
override val displayName: String,
@@ -1073,6 +1082,7 @@ data class GroupMember (
var memberRole: GroupMemberRole,
var memberCategory: GroupMemberCategory,
var memberStatus: GroupMemberStatus,
+ var memberSettings: GroupMemberSettings,
var invitedBy: InvitedBy,
val localDisplayName: String,
val memberProfile: LocalProfile,
@@ -1140,6 +1150,7 @@ data class GroupMember (
memberRole = GroupMemberRole.Member,
memberCategory = GroupMemberCategory.InviteeMember,
memberStatus = GroupMemberStatus.MemComplete,
+ memberSettings = GroupMemberSettings(showMessages = true),
invitedBy = InvitedBy.IBUser(),
localDisplayName = "alice",
memberProfile = LocalProfile.sampleData,
@@ -1151,11 +1162,20 @@ data class GroupMember (
}
@Serializable
-class GroupMemberRef(
+data class GroupMemberSettings(val showMessages: Boolean) {}
+
+@Serializable
+data class GroupMemberRef(
val groupMemberId: Long,
val profile: Profile
)
+@Serializable
+data class GroupMemberIds(
+ val groupMemberId: Long,
+ val groupId: Long
+)
+
@Serializable
enum class GroupMemberRole(val memberRole: String) {
@SerialName("observer") Observer("observer"), // order matters in comparisons
@@ -1249,7 +1269,7 @@ class LinkPreview (
@Serializable
class MemberSubError (
- val member: GroupMember,
+ val member: GroupMemberIds,
val memberError: ChatError
)
@@ -1844,6 +1864,7 @@ enum class SndCIStatusProgress {
@Serializable
sealed class CIDeleted {
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
+ @Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted()
@Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index 43043d65f..f49984b94 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
+import chat.simplex.common.model.ChatModel.updatingChatsMutex
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
@@ -16,6 +17,7 @@ import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR
import com.russhwolf.settings.Settings
import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
@@ -336,6 +338,7 @@ object ChatController {
apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath)
apiSetXFTPConfig(getXFTPCfg())
+// apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
val justStarted = apiStartChat()
val users = listUsers()
chatModel.users.clear()
@@ -349,8 +352,10 @@ object ChatController {
startReceiver()
Log.d(TAG, "startChat: started")
} else {
- val chats = apiGetChats()
- chatModel.updateChats(chats)
+ updatingChatsMutex.withLock {
+ val chats = apiGetChats()
+ chatModel.updateChats(chats)
+ }
Log.d(TAG, "startChat: running")
}
} catch (e: Error) {
@@ -384,8 +389,10 @@ object ChatController {
suspend fun getUserChatData() {
chatModel.userAddress.value = apiGetUserAddress()
chatModel.chatItemTTL.value = getChatItemTTL()
- val chats = apiGetChats()
- chatModel.updateChats(chats)
+ updatingChatsMutex.withLock {
+ val chats = apiGetChats()
+ chatModel.updateChats(chats)
+ }
}
private fun startReceiver() {
@@ -553,6 +560,8 @@ object ChatController {
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
}
+ suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable))
+
suspend fun apiExportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiExportArchive(config))
if (r is CR.CmdOk) return
@@ -1076,6 +1085,13 @@ object ChatController {
return r is CR.CmdOk
}
+ suspend fun apiGetNetworkStatuses(): List? {
+ val r = sendCmd(CC.ApiGetNetworkStatuses())
+ if (r is CR.NetworkStatuses) return r.networkStatuses
+ Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}")
+ return null
+ }
+
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true
@@ -1314,6 +1330,13 @@ object ChatController {
}
}
+ private suspend fun sendCommandOkResp(cmd: CC): Boolean {
+ val r = sendCmd(cmd)
+ val ok = r is CR.CmdOk
+ if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r)
+ return ok
+ }
+
suspend fun apiGetVersion(): CoreVersionInfo? {
val r = sendCmd(CC.ShowVersion())
return if (r is CR.VersionInfo) {
@@ -1419,12 +1442,6 @@ object ChatController {
}
is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected())
is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected())
- is CR.ContactSubError -> {
- if (active(r.user)) {
- chatModel.updateContact(r.contact)
- }
- processContactSubError(r.contact, r.chatError)
- }
is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) {
if (active(r.user)) {
@@ -1438,6 +1455,16 @@ object ChatController {
}
}
}
+ is CR.NetworkStatusResp -> {
+ for (cId in r.connections) {
+ chatModel.networkStatuses[cId] = r.networkStatus
+ }
+ }
+ is CR.NetworkStatuses -> {
+ for (s in r.networkStatuses) {
+ chatModel.networkStatuses[s.agentConnId] = s.networkStatus
+ }
+ }
is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
@@ -1841,6 +1868,7 @@ sealed class CC {
class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
+ class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
@@ -1909,11 +1937,12 @@ sealed class CC {
class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
class ApiEndCall(val contact: Contact): CC()
class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
+ class ApiGetNetworkStatuses(): CC()
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
- class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
+ class ReceiveFile(val fileId: Long, val encrypt: Boolean?, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
class ShowVersion(): CC()
@@ -1945,6 +1974,7 @@ sealed class CC {
is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
+ is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
@@ -2018,9 +2048,13 @@ sealed class CC {
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
is ApiEndCall -> "/_call end @${contact.apiId}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
+ is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
- is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
+ is ReceiveFile ->
+ "/freceive $fileId" +
+ (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") +
+ (if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
is ShowVersion -> "/version"
}
@@ -2044,6 +2078,7 @@ sealed class CC {
is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
+ is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
@@ -2114,6 +2149,7 @@ sealed class CC {
is ApiSendCallExtraInfo -> "apiSendCallExtraInfo"
is ApiEndCall -> "apiEndCall"
is ApiCallStatus -> "apiCallStatus"
+ is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
is ApiChatRead -> "apiChatRead"
is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile"
@@ -2472,15 +2508,22 @@ data class KeepAliveOpts(
@Serializable
data class ChatSettings(
- val enableNtfs: Boolean,
+ val enableNtfs: MsgFilter,
val sendRcpts: Boolean?,
val favorite: Boolean
) {
companion object {
- val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false)
+ val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false)
}
}
+@Serializable
+enum class MsgFilter {
+ @SerialName("all") All,
+ @SerialName("none") None,
+ @SerialName("mentions") Mentions,
+}
+
@Serializable
data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean)
@@ -3320,11 +3363,14 @@ sealed class CR {
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
+ // TODO remove below
@Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR()
@Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR()
- @Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR()
- @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR()
+ // TODO remove above
+ @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR()
+ @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR()
+ @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@@ -3454,8 +3500,9 @@ sealed class CR {
is ContactUpdated -> "contactUpdated"
is ContactsSubscribed -> "contactsSubscribed"
is ContactsDisconnected -> "contactsDisconnected"
- is ContactSubError -> "contactSubError"
is ContactSubSummary -> "contactSubSummary"
+ is NetworkStatusResp -> "networkStatus"
+ is NetworkStatuses -> "networkStatuses"
is GroupSubscribed -> "groupSubscribed"
is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty"
@@ -3583,8 +3630,9 @@ sealed class CR {
is ContactUpdated -> withUser(user, json.encodeToString(toContact))
is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
- is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}")
is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions))
+ is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections"
+ is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses))
is GroupSubscribed -> withUser(user, json.encodeToString(group))
is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
is GroupEmpty -> withUser(user, json.encodeToString(group))
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
index 41c94f21d..566c98181 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
@@ -595,8 +595,8 @@ fun groupInvitationAcceptedAlert() {
)
}
-fun toggleNotifications(chat: Chat, enableNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) {
- val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = enableNtfs)
+fun toggleNotifications(chat: Chat, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) {
+ val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None)
updateChatSettings(chat, chatSettings, chatModel, currentState)
}
@@ -627,7 +627,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
- if (!chatSettings.enableNtfs) {
+ if (chatSettings.enableNtfs != MsgFilter.All) {
ntfManager.cancelNotificationsForChat(chat.id)
}
val current = currentState?.value
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
index fa0f8f54d..0cca35474 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt
@@ -20,11 +20,13 @@ 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.updatingChatsMutex
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import chat.simplex.res.MR
+import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.*
import java.io.*
import java.net.URI
@@ -620,8 +622,10 @@ private fun afterSetCiTTL(
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
withApi {
try {
- val chats = m.controller.apiGetChats()
- m.updateChats(chats)
+ updatingChatsMutex.withLock {
+ val chats = m.controller.apiGetChats()
+ m.updateChats(chats)
+ }
} catch (e: Exception) {
Log.e(TAG, "apiGetChats error: ${e.message}")
}
diff --git a/docs/rfcs/2023-10-12-desktop-calls.md b/docs/rfcs/2023-10-12-desktop-calls.md
new file mode 100644
index 000000000..c956ed011
--- /dev/null
+++ b/docs/rfcs/2023-10-12-desktop-calls.md
@@ -0,0 +1,34 @@
+# Desktop calls
+
+To make audio and video calls on desktop there are some options:
+- adapt [libwebrtc](webrtc.googlesource.com/) from Google which would be the most reliable, performant and seamless solution;
+- include some kind of WebView to the app via libraries;
+- implement a signaling server in the app and let users to use HTML page with WebRTC code that already exist for Android.
+
+## WebRTC lib
+
+To adapt libwebrtc we need to make SDK that is compatible with Java (JNI layer + desktop implementation of VideoCodecs and other features). There are two SDKs exist already: for Android and for Objective-C. Making Android SDK compatible with Java-only SDK gives a lot of problems and requires to have 5+ professional C++ developers with weeks/months for developing. Which is not something good.
+
+Another considered option was adopting Jitsi Java SDK, but it's state is not very clear, and in any case it is much more effort.
+
+## WebView
+
+Including WebView is possible but requires a lot of megabytes of storage to waste on such libs. Because only Chromium-like WebViews support WebRTC features. Which means 100+ MB to the package on top of 200+ MB now.
+
+## Standalone browser + WebRTC HTML page
+
+The last solution is what can give the most useful result: the same package size as before + already existent code which can be reused with small modifications + quality of result will depend on Chromium/Firefox/Safari devs (which is good, since they are interested in making all features working for everyone).
+
+# Details of implementation
+
+Since the code for WebView has already written (https://github.com/simplex-chat/simplex-chat/tree/0e4376bada2d0c4ec2ade7f30b0048dc8b13abd8/apps/multiplatform/android/src/main/assets/www) it can be used to make calls on a desktop browser too. The only differences are these:
+- UI needs to be changed - buttons controlling calls should be added. For example, end call, disable camera/mic. This will be added to separate HTML and JS files that would communication with the existing WebRTC JS code via the existing functional API.
+- signaling websocket server should be started in order to allow Haskell backend to talk with HTML page and to exchange messages between all parties. It is bidirectional communication between server and webpage since both parties have to send messages to each other.
+
+There is no need to have TLS-secured connection between server and webpage since it's only used locally. And browser will not be happy with self-signed certificate too.
+
+After accepting the call, webpage will be opened in the default browser. URL will look like: `https://localhost:123/simplex/call/` (to reduce questions and to namespace other files - the main UI page will be index.html, that would load via folder path) After that internal machinary will connect both parties together. Same as on Android but with a different signaling channel, in this case it's websockets.
+
+Ending the call by the user will also send a new action to signaling server to allow the backend to notify other party.
+
+The solution will also allow to make screen sharing in the future that will be supported on every OS and graphics environment where the user's browser supports it.
\ No newline at end of file
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index f0c079cf5..a055a7334 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -139,7 +139,8 @@ defaultChatConfig =
initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds
cleanupManagerInterval = 30 * 60, -- 30 minutes
cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds
- ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes
+ ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes
+ coreApi = False
}
_defaultSMPServers :: NonEmpty SMPServerWithAuth
@@ -191,6 +192,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
idsDrg <- newTVarIO =<< liftIO drgNew
inputQ <- newTBQueueIO tbqSize
outputQ <- newTBQueueIO tbqSize
+ connNetworkStatuses <- atomically TM.empty
subscriptionMode <- newTVarIO SMSubscribe
chatLock <- newEmptyTMVarIO
sndFiles <- newTVarIO M.empty
@@ -203,6 +205,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
cleanupManagerAsync <- newTVarIO Nothing
timedItemThreads <- atomically TM.empty
showLiveItems <- newTVarIO False
+ encryptLocalFiles <- newTVarIO False
userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg
tempDirectory <- newTVarIO tempDir
contactMergeEnabled <- newTVarIO True
@@ -217,6 +220,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
idsDrg,
inputQ,
outputQ,
+ connNetworkStatuses,
subscriptionMode,
chatLock,
sndFiles,
@@ -229,6 +233,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
cleanupManagerAsync,
timedItemThreads,
showLiveItems,
+ encryptLocalFiles,
userXFTPFileConfig,
tempDirectory,
logFilePath = logFile,
@@ -508,6 +513,7 @@ processChatCommand = \case
APISetXFTPConfig cfg -> do
asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg)
ok_
+ APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_
SetContactMergeEnabled onOff -> do
asks contactMergeEnabled >>= atomically . (`writeTVar` onOff)
ok_
@@ -1080,6 +1086,8 @@ processChatCommand = \case
user <- getUserByContactId db contactId
contact <- getContact db user contactId
pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs}
+ APIGetNetworkStatuses -> withUser $ \_ ->
+ CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses
APICallStatus contactId receivedStatus ->
withCurrentCall contactId $ \user ct call ->
updateCallItemStatus user ct call receivedStatus Nothing $> Just call
@@ -1680,6 +1688,8 @@ processChatCommand = \case
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode
-- [incognito] reuse membership incognito profile
ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode
+ -- TODO not sure it is correct to set connections status here?
+ setContactNetworkStatus ct NSConnected
pure $ CRNewMemberContact user ct g m
_ -> throwChatError CEGroupMemberNotActive
APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do
@@ -1758,19 +1768,16 @@ processChatCommand = \case
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
- ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ ->
+ ReceiveFile fileId encrypted_ rcvInline_ filePath_ -> withUser $ \_ ->
withChatLock "receiveFile" . procCmd $ do
(user, ft) <- withStore (`getRcvFileTransferById` fileId)
- ft' <- if encrypted then encryptLocalFile ft else pure ft
+ encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles
+ ft' <- (if encrypt then setFileToEncrypt else pure) ft
receiveFile' user ft' rcvInline_ filePath_
- where
- encryptLocalFile ft = do
- cfArgs <- liftIO $ CF.randomArgs
- withStore' $ \db -> setFileCryptoArgs db fileId cfArgs
- pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs}
- SetFileToReceive fileId encrypted -> withUser $ \_ -> do
+ SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do
withChatLock "setFileToReceive" . procCmd $ do
- cfArgs <- if encrypted then Just <$> liftIO CF.randomArgs else pure Nothing
+ encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles
+ cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing
withStore' $ \db -> setRcvFileToReceive db fileId cfArgs
ok_
CancelFile fileId -> withUser $ \user@User {userId} ->
@@ -2395,6 +2402,12 @@ toFSFilePath :: ChatMonad' m => FilePath -> m FilePath
toFSFilePath f =
maybe f (> f) <$> (readTVarIO =<< asks filesFolder)
+setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer
+setFileToEncrypt ft@RcvFileTransfer {fileId} = do
+ cfArgs <- liftIO CF.randomArgs
+ withStore' $ \db -> setFileCryptoArgs db fileId cfArgs
+ pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs}
+
receiveFile' :: ChatMonad m => User -> RcvFileTransfer -> Maybe Bool -> Maybe FilePath -> m ChatResponse
receiveFile' user ft rcvInline_ filePath_ = do
(CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchChatError` processError
@@ -2619,6 +2632,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
rs <- withAgent $ \a -> agentBatchSubscribe a conns
-- send connection events to view
contactSubsToView rs cts ce
+-- TODO possibly, we could either disable these events or replace with less noisy for API
contactLinkSubsToView rs ucs
groupSubsToView rs gs ms ce
sndFileSubsToView rs sfts
@@ -2679,12 +2693,30 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
let connIds = map aConnId' pcs
pure (connIds, M.fromList $ zip connIds pcs)
contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m ()
- contactSubsToView rs cts ce = do
- toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs
- when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors
+ contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI
where
- cRs = resultsFor rs cts
- cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs
+ notifyCLI = do
+ let cRs = resultsFor rs cts
+ cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs
+ toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs
+ when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors
+ notifyAPI = do
+ let statuses = M.foldrWithKey' addStatus [] cts
+ chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses)
+ toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses
+ where
+ addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)]
+ addStatus connId ct =
+ let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs)
+ in (ns :)
+ netStatus :: Maybe ChatError -> NetworkStatus
+ netStatus = maybe NSConnected $ NSError . errorNetworkStatus
+ errorNetworkStatus :: ChatError -> String
+ errorNetworkStatus = \case
+ ChatErrorAgent (BROKER _ NETWORK) _ -> "network"
+ ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted"
+ e -> show e
+-- TODO possibly below could be replaced with less noisy events for API
contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m ()
contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m ()
@@ -2734,12 +2766,12 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
resultsFor rs = M.foldrWithKey' addResult []
where
addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)]
- addResult connId = (:) . (,err)
- where
- err = case M.lookup connId rs of
- Just (Left e) -> Just $ ChatErrorAgent e Nothing
- Just _ -> Nothing
- _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId
+ addResult connId = (:) . (,resultErr connId rs)
+ resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError
+ resultErr connId rs = case M.lookup connId rs of
+ Just (Left e) -> Just $ ChatErrorAgent e Nothing
+ Just _ -> Nothing
+ _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId
cleanupManager :: forall m. ChatMonad m => m ()
cleanupManager = do
@@ -2884,16 +2916,22 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone ->
processAgentMessageNoConn = \case
CONNECT p h -> hostEvent $ CRHostConnected p h
DISCONNECT p h -> hostEvent $ CRHostDisconnected p h
- DOWN srv conns -> serverEvent srv conns CRContactsDisconnected
- UP srv conns -> serverEvent srv conns CRContactsSubscribed
+ DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected
+ UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed
SUSPENDED -> toView CRChatSuspended
DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId
where
hostEvent :: ChatResponse -> m ()
hostEvent = whenM (asks $ hostEvents . config) . toView
- serverEvent srv conns event = do
- cs <- withStore' (`getConnectionsContacts` conns)
- toView $ event srv cs
+ serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI
+ where
+ notifyAPI = do
+ let connIds = map AgentConnId conns
+ chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds
+ toView $ CRNetworkStatus nsStatus connIds
+ notifyCLI = do
+ cs <- withStore' (`getConnectionsContacts` conns)
+ toView $ event srv cs
processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m ()
processAgentMsgSndFile _corrId aFileId msg =
@@ -3180,6 +3218,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
Nothing -> do
-- [incognito] print incognito profile used for this contact
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
+ setContactNetworkStatus ct NSConnected
toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile)
when (directOrUsed ct) $ createFeatureEnabledItems ct
when (contactConnInitiated conn) $ do
@@ -3753,6 +3792,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m ()
notifyMemberConnected gInfo m ct_ = do
memberConnectedChatItem gInfo m
+ mapM_ (`setContactNetworkStatus` NSConnected) ct_
toView $ CRConnectedToGroupMember user gInfo m ct_
probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m ()
@@ -3888,14 +3928,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
inline <- receiveInlineMode fInv (Just mc) fileChunkSize
ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
- (filePath, fileStatus) <- case inline of
+ (filePath, fileStatus, ft') <- case inline of
Just IFMSent -> do
+ encrypt <- chatReadVar encryptLocalFiles
+ ft' <- (if encrypt then setFileToEncrypt else pure) ft
fPath <- getRcvFilePath fileId Nothing fileName True
- withStore' $ \db -> startRcvInlineFT db user ft fPath inline
- pure (Just fPath, CIFSRcvAccepted)
- _ -> pure (Nothing, CIFSRcvInvitation)
- let fileSource = CF.plain <$> filePath
- pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol})
+ withStore' $ \db -> startRcvInlineFT db user ft' fPath inline
+ pure (Just fPath, CIFSRcvAccepted, ft')
+ _ -> pure (Nothing, CIFSRcvInvitation, ft)
+ let RcvFileTransfer {cryptoArgs} = ft'
+ fileSource = (`CryptoFile` cryptoArgs) <$> filePath
+ pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol})
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m ()
messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do
@@ -5524,6 +5567,7 @@ chatCommandP =
("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath),
"/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))),
"/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))),
+ "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP),
"/contact_merge " *> (SetContactMergeEnabled <$> onOffP),
"/_db export " *> (APIExportArchive <$> jsonP),
"/db export" $> ExportArchive,
@@ -5560,6 +5604,7 @@ chatCommandP =
"/_call end @" *> (APIEndCall <$> A.decimal),
"/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP),
"/_call get" $> APIGetCallInvitations,
+ "/_network_statuses" $> APIGetNetworkStatuses,
"/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP),
"/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
@@ -5699,8 +5744,8 @@ chatCommandP =
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal),
("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath),
- ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)),
- "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)),
+ ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)),
+ "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> optional (" encrypt=" *> onOffP)),
("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal),
("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal),
"/simplex" *> (ConnectSimplex <$> incognitoP),
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index af9f34d2d..0a9b88694 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -32,6 +32,7 @@ import Data.Char (ord)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as M
import Data.String
import Data.Text (Text)
import Data.Time (NominalDiffTime, UTCTime)
@@ -124,7 +125,8 @@ data ChatConfig = ChatConfig
initialCleanupManagerDelay :: Int64,
cleanupManagerInterval :: NominalDiffTime,
cleanupManagerStepDelay :: Int64,
- ciExpirationInterval :: Int64 -- microseconds
+ ciExpirationInterval :: Int64, -- microseconds
+ coreApi :: Bool
}
data DefaultAgentServers = DefaultAgentServers
@@ -164,6 +166,7 @@ data ChatController = ChatController
idsDrg :: TVar ChaChaDRG,
inputQ :: TBQueue String,
outputQ :: TBQueue (Maybe CorrId, ChatResponse),
+ connNetworkStatuses :: TMap AgentConnId NetworkStatus,
subscriptionMode :: TVar SubscriptionMode,
chatLock :: Lock,
sndFiles :: TVar (Map Int64 Handle),
@@ -176,6 +179,7 @@ data ChatController = ChatController
cleanupManagerAsync :: TVar (Maybe (Async ())),
timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))),
showLiveItems :: TVar Bool,
+ encryptLocalFiles :: TVar Bool,
userXFTPFileConfig :: TVar (Maybe XFTPFileConfig),
tempDirectory :: TVar (Maybe FilePath),
logFilePath :: Maybe FilePath,
@@ -218,6 +222,7 @@ data ChatCommand
| SetTempFolder FilePath
| SetFilesFolder FilePath
| APISetXFTPConfig (Maybe XFTPFileConfig)
+ | APISetEncryptLocalFiles Bool
| SetContactMergeEnabled Bool
| APIExportArchive ArchiveConfig
| ExportArchive
@@ -251,6 +256,7 @@ data ChatCommand
| APIEndCall ContactId
| APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus
+ | APIGetNetworkStatuses
| APIUpdateProfile UserId Profile
| APISetContactPrefs ContactId Preferences
| APISetContactAlias ContactId LocalAlias
@@ -389,8 +395,8 @@ data ChatCommand
| ForwardFile ChatName FileTransferId
| ForwardImage ChatName FileTransferId
| SendFileDescription ChatName FilePath
- | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath}
- | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool}
+ | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Maybe Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath}
+ | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Maybe Bool}
| CancelFile FileTransferId
| FileStatus FileTransferId
| ShowProfile -- UserId (not used in UI)
@@ -528,6 +534,8 @@ data ChatResponse
| CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError}
| CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]}
| CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]}
+ | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]}
+ | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]}
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost}
| CRGroupInvitation {user :: User, groupInfo :: GroupInfo}
@@ -1044,6 +1052,13 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m ()
chatWriteVar f value = asks f >>= atomically . (`writeTVar` value)
{-# INLINE chatWriteVar #-}
+chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m ()
+chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue)
+{-# INLINE chatModifyVar #-}
+
+setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m ()
+setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct)
+
tryChatError :: ChatMonad m => m a -> m (Either ChatError a)
tryChatError = tryAllErrors mkChatError
{-# INLINE tryChatError #-}
diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs
index 0a970d2c8..b44488814 100644
--- a/src/Simplex/Chat/Mobile.hs
+++ b/src/Simplex/Chat/Mobile.hs
@@ -169,7 +169,8 @@ defaultMobileConfig :: ChatConfig
defaultMobileConfig =
defaultChatConfig
{ confirmMigrations = MCYesUp,
- logLevel = CLLError
+ logLevel = CLLError,
+ coreApi = True
}
getActiveUser_ :: SQLiteStore -> IO (Maybe User)
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index 745d2057a..24a224b11 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -190,6 +190,9 @@ instance ToJSON Contact where
contactConn :: Contact -> Connection
contactConn Contact {activeConn} = activeConn
+contactAgentConnId :: Contact -> AgentConnId
+contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId
+
contactConnId :: Contact -> ConnId
contactConnId = aConnId . contactConn
@@ -1138,13 +1141,16 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft
fp RcvFileInfo {filePath} = filePath
newtype AgentConnId = AgentConnId ConnId
- deriving (Eq, Show)
+ deriving (Eq, Ord, Show)
instance StrEncoding AgentConnId where
strEncode (AgentConnId connId) = strEncode connId
strDecode s = AgentConnId <$> strDecode s
strP = AgentConnId <$> strP
+instance FromJSON AgentConnId where
+ parseJSON = strParseJSON "AgentConnId"
+
instance ToJSON AgentConnId where
toJSON = strToJSON
toEncoding = strToJEncoding
@@ -1475,6 +1481,35 @@ serializeIntroStatus = \case
textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a
textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode
+data NetworkStatus
+ = NSUnknown
+ | NSConnected
+ | NSDisconnected
+ | NSError {connectionError :: String}
+ deriving (Eq, Ord, Show, Generic)
+
+netStatusStr :: NetworkStatus -> String
+netStatusStr = \case
+ NSUnknown -> "unknown"
+ NSConnected -> "connected"
+ NSDisconnected -> "disconnected"
+ NSError e -> "error: " <> e
+
+instance FromJSON NetworkStatus where
+ parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS"
+
+instance ToJSON NetworkStatus where
+ toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS"
+ toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS"
+
+data ConnNetworkStatus = ConnNetworkStatus
+ { agentConnId :: AgentConnId,
+ networkStatus :: NetworkStatus
+ }
+ deriving (Show, Generic, FromJSON)
+
+instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions
+
type CommandId = Int64
aCorrId :: CommandId -> ACorrId
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index ff72f9b4c..212ceeff2 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -18,7 +18,7 @@ import Data.Char (isSpace, toUpper)
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
-import Data.List.NonEmpty (NonEmpty)
+import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
@@ -165,7 +165,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRRcvFileDescrReady _ _ -> []
CRRcvFileDescrNotReady _ _ -> []
CRRcvFileProgressXFTP {} -> []
- CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci
+ CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft
CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts
CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft
@@ -177,10 +177,10 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c'
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
- CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci
- CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci
+ CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci
+ CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
- CRRcvFileError u ci e -> ttyUser u $ receivingFile_' "error" ci <> [sShow e]
+ CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e]
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
CRSndFileStartXFTP {} -> []
@@ -209,6 +209,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
(addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary
addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError
(groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks
+ CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else []
+ CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else []
CRGroupInvitation u g -> ttyUser u [groupInvitation' g]
CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r
CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g
@@ -797,6 +799,12 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString]
viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"]
viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"]
+viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString]
+viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus
+ where
+ netStatus ConnNetworkStatus {networkStatus} = networkStatus
+ viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s)
+
viewUserJoinedGroup :: GroupInfo -> [StyledString]
viewUserJoinedGroup g =
case incognitoMembershipProfile g of
@@ -1438,27 +1446,28 @@ humanReadableSize size
mB = kB * 1024
gB = mB * 1024
-savingFile' :: Bool -> AChatItem -> [StyledString]
-savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) =
- let from = case (chat, chatDir) of
- (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c
- (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m
- _ -> ""
- in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr
- where
- cfArgsStr = case cfArgs_ of
- Just cfArgs@(CFArgs key nonce)
- | testView -> [plain $ LB.unpack $ J.encode cfArgs]
- | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce]
- _ -> []
-savingFile' _ _ = ["saving file"] -- shouldn't happen
+savingFile' :: AChatItem -> [StyledString]
+savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath _)}, chatDir}) =
+ ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath]
+savingFile' _ = ["saving file"] -- shouldn't happen
-receivingFile_' :: StyledString -> AChatItem -> [StyledString]
-receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) =
- [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c]
-receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) =
- [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m]
-receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen
+receivingFile_' :: Bool -> String -> AChatItem -> [StyledString]
+receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) =
+ [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_
+ where
+ cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"]
+ where
+ s =
+ if testView
+ then LB.toStrict $ J.encode cfArgs
+ else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce
+ cfArgsStr _ = []
+receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen
+
+fileFrom :: ChatInfo c -> CIDirection c d -> StyledString
+fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct
+fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m
+fileFrom _ _ = ""
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs
index b4c3c53cd..677bc4c09 100644
--- a/tests/ChatTests/Direct.hs
+++ b/tests/ChatTests/Direct.hs
@@ -119,6 +119,8 @@ chatDirectTests = do
testReqVRange vr11 supportedChatVRange
testReqVRange vr11 vr11
it "update peer version range on received messages" testUpdatePeerChatVRange
+ describe "network statuses" $ do
+ it "should get network statuses" testGetNetworkStatuses
where
testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2
testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2
@@ -2623,6 +2625,20 @@ testUpdatePeerChatVRange tmp =
where
cfg11 = testCfg {chatVRange = vr11} :: ChatConfig
+testGetNetworkStatuses :: HasCallStack => FilePath -> IO ()
+testGetNetworkStatuses tmp = do
+ withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do
+ withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do
+ connectUsers alice bob
+ alice ##> "/_network_statuses"
+ alice <## "1 connections connected"
+ withTestChatCfg tmp cfg "alice" $ \alice ->
+ withTestChatCfg tmp cfg "bob" $ \bob -> do
+ alice <## "1 connections connected"
+ bob <## "1 connections connected"
+ where
+ cfg = testCfg {coreApi = True}
+
vr11 :: VersionRange
vr11 = mkVersionRange 1 1
diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs
index 11a3c4b21..3332736ab 100644
--- a/tests/ChatTests/Files.hs
+++ b/tests/ChatTests/Files.hs
@@ -31,6 +31,7 @@ chatFileTests = do
describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer
describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted
it "send and receive file inline (without accepting)" testInlineFileTransfer
+ it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted
xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer
it "send and receive small file inline (default config)" testSmallInlineFileTransfer
it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored
@@ -105,7 +106,6 @@ runTestFileTransferEncrypted alice bob = do
bob <## "use /fr 1 [/ | ] to receive it"
bob ##> "/fr 1 encrypt=on ./tests/tmp"
bob <## "saving file 1 from alice to ./tests/tmp/test.pdf"
- Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob
concurrently_
(bob <## "started receiving file 1 (test.pdf) from alice")
(alice <## "started sending file 1 (test.pdf) to bob")
@@ -121,6 +121,7 @@ runTestFileTransferEncrypted alice bob = do
"completed sending file 1 (test.pdf) to bob"
]
]
+ Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob
src <- B.readFile "./tests/fixtures/test.pdf"
-- dest <- B.readFile "./tests/tmp/test.pdf"
-- dest `shouldBe` src
@@ -152,6 +153,34 @@ testInlineFileTransfer =
where
cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}}
+testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO ()
+testInlineFileTransferEncrypted =
+ testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do
+ connectUsers alice bob
+ bob ##> "/_files_folder ./tests/tmp/"
+ bob <## "ok"
+ bob ##> "/_files_encrypt on"
+ bob <## "ok"
+ alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}"
+ alice <# "@bob voice message (00:10)"
+ alice <# "/f @bob ./tests/fixtures/test.jpg"
+ -- below is not shown in "sent" mode
+ -- alice <## "use /fc 1 to cancel sending"
+ bob <# "alice> voice message (00:10)"
+ bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
+ -- below is not shown in "sent" mode
+ -- bob <## "use /fr 1 [/ | ] to receive it"
+ bob <## "started receiving file 1 (test.jpg) from alice"
+ concurrently_
+ (alice <## "completed sending file 1 (test.jpg) to bob")
+ (bob <## "completed receiving file 1 (test.jpg) from alice")
+ Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob
+ src <- B.readFile "./tests/fixtures/test.jpg"
+ Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce)
+ LB.toStrict dest `shouldBe` src
+ where
+ cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}}
+
testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO ()
testAcceptInlineFileSndCancelDuringTransfer =
testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do
@@ -1075,10 +1104,10 @@ testXFTPFileTransferEncrypted =
bob <## "use /fr 1 [/ | ] to receive it"
bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/"
bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf"
- Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob
alice <## "completed uploading file 1 (test.pdf) for bob"
bob <## "started receiving file 1 (test.pdf) from alice"
bob <## "completed receiving file 1 (test.pdf) from alice"
+ Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob
Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce)
LB.length dest `shouldBe` fromIntegral srcLen
LB.toStrict dest `shouldBe` src
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index 55d02b948..4c3180526 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -2461,7 +2461,7 @@ testPlanGroupLinkLeaveRejoin =
concurrentlyN_
[ alice
<### [ "bob_1 (Bob): contact is connected",
- "bob_1 invited to group #team via your group link",
+ EndsWith "invited to group #team via your group link",
EndsWith "joined the group",
"contact bob_1 is merged into bob",
"use @bob to send messages"
@@ -3278,7 +3278,7 @@ testMemberContactInvitedConnectionReplaced tmp = do
bob ##> "/_get chat @2 count=100"
items <- chat <$> getTermLine bob
- items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")]
+ items `shouldContain` [(0, "security code changed")]
withTestChat tmp "bob" $ \bob -> do
subscriptions bob 1
diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs
index 0d7683c4d..80e170922 100644
--- a/tests/ChatTests/Profiles.hs
+++ b/tests/ChatTests/Profiles.hs
@@ -650,8 +650,10 @@ testPlanAddressConnecting tmp = do
alice ##> "/ad"
getContactLink alice True
withNewTestChat tmp "bob" bobProfile $ \bob -> do
+ threadDelay 100000
bob ##> ("/c " <> cLink)
bob <## "connection request sent!"
+ threadDelay 100000
withTestChat tmp "alice" $ \alice -> do
alice <## "Your address is active! To show: /sa"
alice <## "bob (Bob) wants to connect to you!"
diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs
index 692dd5884..aa36b397a 100644
--- a/tests/MobileTests.hs
+++ b/tests/MobileTests.hs
@@ -120,19 +120,19 @@ chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}"
chatStartedTagged :: LB.ByteString
chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}"
-contactSubSummary :: LB.ByteString
-contactSubSummary =
+networkStatuses :: LB.ByteString
+networkStatuses =
#if defined(darwin_HOST_OS) && defined(swiftJSON)
- contactSubSummarySwift
+ networkStatusesSwift
#else
- contactSubSummaryTagged
+ networkStatusesTagged
#endif
-contactSubSummarySwift :: LB.ByteString
-contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}"
+networkStatusesSwift :: LB.ByteString
+networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}"
-contactSubSummaryTagged :: LB.ByteString
-contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}"
+networkStatusesTagged :: LB.ByteString
+networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}"
memberSubSummary :: LB.ByteString
memberSubSummary =
@@ -143,10 +143,10 @@ memberSubSummary =
#endif
memberSubSummarySwift :: LB.ByteString
-memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}"
+memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}"
memberSubSummaryTagged :: LB.ByteString
-memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}"
+memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}"
userContactSubSummary :: LB.ByteString
userContactSubSummary =
@@ -157,10 +157,10 @@ userContactSubSummary =
#endif
userContactSubSummarySwift :: LB.ByteString
-userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}"
+userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}"
userContactSubSummaryTagged :: LB.ByteString
-userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}"
+userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}"
pendingSubSummary :: LB.ByteString
pendingSubSummary =
@@ -171,13 +171,13 @@ pendingSubSummary =
#endif
pendingSubSummarySwift :: LB.ByteString
-pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}"
+pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}"
pendingSubSummaryTagged :: LB.ByteString
-pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}"
+pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}"
userJSON :: LB.ByteString
-userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}"
+userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}"
parsedMarkdown :: LB.ByteString
parsedMarkdown =
@@ -215,7 +215,7 @@ testChatApi tmp = do
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted
- chatRecvMsg cc `shouldReturn` contactSubSummary
+ chatRecvMsg cc `shouldReturn` networkStatuses
chatRecvMsg cc `shouldReturn` userContactSubSummary
chatRecvMsg cc `shouldReturn` memberSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary
diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html
index a03a3aea4..6beb148f8 100644
--- a/website/src/_includes/contact_page.html
+++ b/website/src/_includes/contact_page.html
@@ -152,7 +152,7 @@
v1.0.0+, {{ "copy-the-command-below-text" | i18n({}, lang ) | safe }}
- /c https://simplex.chat/contact#/?v=1&smp=smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im/KBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23MCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go=
+