Compare commits

..

18 Commits

Author SHA1 Message Date
Evgeny Poberezkin
8cc605ffb9 Merge branch 'master' into av/ios-migrate-to-device 2024-02-25 22:36:08 +00:00
Avently
fa2bdaf477 migrating to device 2024-02-23 23:04:29 +07:00
Avently
ac7a3e5b96 changes 2024-02-22 21:24:19 +07:00
Avently
f06cef49a3 changed directory 2024-02-22 20:08:24 +07:00
Avently
be7730f1be test 2024-02-22 19:49:46 +07:00
Avently
90f333cfc6 better animation 2024-02-22 19:48:05 +07:00
Avently
ade02fd873 animation fix 2024-02-22 19:39:05 +07:00
Evgeny Poberezkin
0e6dd6f4a0 Merge branch 'master' into av/ios-migrate-to-device 2024-02-22 12:20:08 +00:00
Avently
cb694ad89b Merge branch 'master' into av/ios-migrate-to-device 2024-02-22 19:01:07 +07:00
Avently
bbeeaac6ca formatting 2024-02-22 18:57:51 +07:00
Avently
77d06e6764 UI, API, logic 2024-02-22 18:46:09 +07:00
Avently
a7eaf4ec0f simplify statement 2024-02-21 23:31:42 +07:00
Avently
0c65d88e13 UI and logic 2024-02-21 23:27:07 +07:00
Avently
3e64ef96b1 UI and API changes 2024-02-21 00:44:24 +07:00
Avently
350a6bff00 Merge branch 'master' into av/ios-migrate-to-device 2024-02-19 19:45:54 +07:00
Avently
665cdd9b0e UI 2024-02-19 19:38:06 +07:00
Avently
0f1f9893cc changes in UI 2024-02-17 02:03:14 +07:00
Avently
701f5d7cdc ios: migration via link 2024-02-15 01:05:19 +07:00
28 changed files with 1648 additions and 411 deletions

View File

@@ -90,12 +90,12 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r return r
} }
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)") logger.debug("chatSendCmd \(cmd.cmdType)")
let start = Date.now let start = Date.now
let resp = bgTask let resp = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
: sendSimpleXCmd(cmd) : sendSimpleXCmd(cmd, ctrl)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp { if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
@@ -106,24 +106,24 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp return resp
} }
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse { func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay)) cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
} }
} }
func chatRecvMsg() async -> ChatResponse? { func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? {
await withCheckedContinuation { cont in await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
let resp = recvSimpleXMsg() let resp = recvSimpleXMsg(ctrl)
cont.resume(returning: resp) cont.resume(returning: resp)
return resp return resp
} }
} }
} }
func apiGetActiveUser() throws -> User? { func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
let r = chatSendCmdSync(.showActiveUser) let r = chatSendCmdSync(.showActiveUser, ctrl)
switch r { switch r {
case let .activeUser(user): return user case let .activeUser(user): return user
case .chatCmdError(_, .error(.noActiveUser)): return nil case .chatCmdError(_, .error(.noActiveUser)): return nil
@@ -131,8 +131,8 @@ func apiGetActiveUser() throws -> User? {
} }
} }
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User { func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp)) let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
if case let .activeUser(user) = r { return user } if case let .activeUser(user) = r { return user }
throw r throw r
} }
@@ -210,8 +210,8 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
throw r throw r
} }
func apiStartChat() throws -> Bool { func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true)) let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -240,14 +240,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
logger.error("apiSuspendChat error: \(String(describing: r))") logger.error("apiSuspendChat error: \(String(describing: r))")
} }
func apiSetTempFolder(tempFolder: String) throws { func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder)) let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
func apiSetFilesFolder(filesFolder: String) throws { func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
@@ -276,6 +276,10 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey)))
} }
func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws {
try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl)
}
func apiGetChats() throws -> [ChatData] { func apiGetChats() throws -> [ChatData] {
let userId = try currentUserId("apiGetChats") let userId = try currentUserId("apiGetChats")
return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId))) return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId)))
@@ -498,8 +502,8 @@ func getNetworkConfig() async throws -> NetCfg? {
throw r throw r
} }
func setNetworkConfig(_ cfg: NetCfg) throws { func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg)) let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
@@ -864,6 +868,26 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
} }
func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) {
let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl)
if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r {
return (fileTransferMeta, nil)
} else {
logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, String(describing: r))
}
}
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) {
let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl)
if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r {
return (rcvFileTransfer, nil)
} else {
logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, String(describing: r))
}
}
func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) { if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
await chatItemSimpleUpdate(user, chatItem) await chatItemSimpleUpdate(user, chatItem)
@@ -909,8 +933,8 @@ func cancelFile(user: User, fileId: Int64) async {
} }
} }
func apiCancelFile(fileId: Int64) async -> AChatItem? { func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? {
let r = await chatSendCmd(.cancelFile(fileId: fileId)) let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl)
switch r { switch r {
case let .sndFileCancelled(_, chatItem, _, _) : return chatItem case let .sndFileCancelled(_, chatItem, _, _) : return chatItem
case let .rcvFileCancelled(_, chatItem, _) : return chatItem case let .rcvFileCancelled(_, chatItem, _) : return chatItem
@@ -1082,8 +1106,8 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
} }
} }
private func sendCommandOkResp(_ cmd: ChatCommand) async throws { private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws {
let r = await chatSendCmd(cmd) let r = await chatSendCmd(cmd, ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
@@ -1323,6 +1347,16 @@ func startChat(refreshInvitations: Bool = true) throws {
chatLastStartGroupDefault.set(Date.now) chatLastStartGroupDefault.set(Date.now)
} }
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
logger.debug("startChatWithTemporaryDatabase")
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser
}
func changeActiveUser(_ userId: Int64, viewPwd: String?) { func changeActiveUser(_ userId: Int64, viewPwd: String?) {
do { do {
try changeActiveUser_(userId, viewPwd: viewPwd) try changeActiveUser_(userId, viewPwd: viewPwd)
@@ -1701,27 +1735,37 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .rcvFileSndCancelled(user, aChatItem, _): case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) } Task { cleanupFile(aChatItem) }
case let .rcvFileProgressXFTP(user, aChatItem, _, _): case let .rcvFileProgressXFTP(user, aChatItem, _, _, _):
await chatItemSimpleUpdate(user, aChatItem) if let aChatItem = aChatItem {
case let .rcvFileError(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem)
await chatItemSimpleUpdate(user, aChatItem) }
Task { cleanupFile(aChatItem) } case let .rcvFileError(user, aChatItem, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
case let .sndFileStart(user, aChatItem, _): case let .sndFileStart(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _): case let .sndFileComplete(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) } Task { cleanupDirectFile(aChatItem) }
case let .sndFileRcvCancelled(user, aChatItem, _): case let .sndFileRcvCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) if let aChatItem = aChatItem {
Task { cleanupDirectFile(aChatItem) } await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) }
}
case let .sndFileProgressXFTP(user, aChatItem, _, _, _): case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
await chatItemSimpleUpdate(user, aChatItem) if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .sndFileCompleteXFTP(user, aChatItem, _): case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) } Task { cleanupFile(aChatItem) }
case let .sndFileError(user, aChatItem): case let .sndFileError(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) if let aChatItem = aChatItem {
Task { cleanupFile(aChatItem) } await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
case let .callInvitation(invitation): case let .callInvitation(invitation):
await MainActor.run { await MainActor.run {
m.callInvitations[invitation.contact.id] = invitation m.callInvitations[invitation.contact.id] = invitation

View File

@@ -36,6 +36,7 @@ enum DatabaseEncryptionAlert: Identifiable {
struct DatabaseEncryptionView: View { struct DatabaseEncryptionView: View {
@EnvironmentObject private var m: ChatModel @EnvironmentObject private var m: ChatModel
@Binding var useKeychain: Bool @Binding var useKeychain: Bool
var migration: Bool
@State private var alert: DatabaseEncryptionAlert? = nil @State private var alert: DatabaseEncryptionAlert? = nil
@State private var progressIndicator = false @State private var progressIndicator = false
@State private var useKeychainToggle = storeDBPassphraseGroupDefault.get() @State private var useKeychainToggle = storeDBPassphraseGroupDefault.get()
@@ -48,7 +49,12 @@ struct DatabaseEncryptionView: View {
var body: some View { var body: some View {
ZStack { ZStack {
databaseEncryptionView() List {
if migration {
chatStoppedView()
}
databaseEncryptionView()
}
if progressIndicator { if progressIndicator {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
} }
@@ -56,47 +62,49 @@ struct DatabaseEncryptionView: View {
} }
private func databaseEncryptionView() -> some View { private func databaseEncryptionView() -> some View {
List { Section {
Section { if !migration {
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
.onChange(of: useKeychainToggle) { _ in .onChange(of: useKeychainToggle) { _ in
if useKeychainToggle { if useKeychainToggle {
setUseKeychain(true) setUseKeychain(true)
} else if storedKey { } else if storedKey {
alert = .keychainRemoveKey alert = .keychainRemoveKey
} else { } else {
setUseKeychain(false) setUseKeychain(false)
}
} }
} .disabled(initialRandomDBPassphrase)
.disabled(initialRandomDBPassphrase)
} }
}
if !initialRandomDBPassphrase && m.chatDbEncrypted == true { if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
}
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
settingsRow("lock.rotation") {
Button(migration ? "Set passphrase" : "Update database passphrase") {
alert = currentKey == ""
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
: (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey)
} }
}
PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) .disabled(
PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) (m.chatDbEncrypted == true && currentKey == "") ||
currentKey == newKey ||
settingsRow("lock.rotation") { newKey != confirmNewKey ||
Button("Update database passphrase") { newKey == "" ||
alert = currentKey == "" !validKey(currentKey) ||
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) !validKey(newKey)
: (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) )
} } header: {
} Text(migration ? "Database passphrase" : "")
.disabled( } footer: {
(m.chatDbEncrypted == true && currentKey == "") || if !migration {
currentKey == newKey ||
newKey != confirmNewKey ||
newKey == "" ||
!validKey(currentKey) ||
!validKey(newKey)
)
} header: {
Text("")
} footer: {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if m.chatDbEncrypted == false { if m.chatDbEncrypted == false {
Text("Your chat database is not encrypted - set passphrase to encrypt it.") Text("Your chat database is not encrypted - set passphrase to encrypt it.")
@@ -121,6 +129,10 @@ struct DatabaseEncryptionView: View {
} }
.padding(.top, 1) .padding(.top, 1)
.font(.callout) .font(.callout)
} else {
Text("Set database passphrase to migrate it")
.padding(.top, 1)
.font(.callout)
} }
} }
.onAppear { .onAppear {
@@ -346,6 +358,6 @@ func validKey(_ s: String) -> Bool {
struct DatabaseEncryptionView_Previews: PreviewProvider { struct DatabaseEncryptionView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
DatabaseEncryptionView(useKeychain: Binding.constant(true)) DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
} }
} }

View File

@@ -116,7 +116,7 @@ struct DatabaseView: View {
let color: Color = unencrypted ? .orange : .secondary let color: Color = unencrypted ? .orange : .secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) { settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink { NavigationLink {
DatabaseEncryptionView(useKeychain: $useKeychain) DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
.navigationTitle("Database passphrase") .navigationTitle("Database passphrase")
} label: { } label: {
Text("Database passphrase") Text("Database passphrase")
@@ -485,6 +485,10 @@ func deleteChatAsync() async throws {
_ = kcDatabasePassword.remove() _ = kcDatabasePassword.remove()
storeDBPassphraseGroupDefault.set(true) storeDBPassphraseGroupDefault.set(true)
deleteAppDatabaseAndFiles() deleteAppDatabaseAndFiles()
// Clean state so when creating new user the app will start chat automatically (see CreateProfile:createProfile())
DispatchQueue.main.async {
ChatModel.shared.users = []
}
} }
struct DatabaseView_Previews: PreviewProvider { struct DatabaseView_Previews: PreviewProvider {

View File

@@ -216,16 +216,18 @@ struct MigrateToAppGroupView: View {
} }
} }
func exportChatArchive() async throws -> URL { func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
let archiveTime = Date.now let archiveTime = Date.now
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
let archiveName = "simplex-chat.\(ts).zip" let archiveName = "simplex-chat.\(ts).zip"
let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName) let archivePath = (storagePath ?? getDocumentsDirectory()).appendingPathComponent(archiveName)
let config = ArchiveConfig(archivePath: archivePath.path) let config = ArchiveConfig(archivePath: archivePath.path)
try await apiExportArchive(config: config) try await apiExportArchive(config: config)
deleteOldArchive() if storagePath == nil {
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) deleteOldArchive()
chatArchiveTimeDefault.set(archiveTime) UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
chatArchiveTimeDefault.set(archiveTime)
}
return archivePath return archivePath
} }

View File

@@ -0,0 +1,515 @@
//
// MigrateFromAnotherDevice.swift
// SimpleX (iOS)
//
// Created by Avently on 23.02.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
private enum MigrationState: Equatable {
case pasteOrScanLink(link: String)
case linkDownloading(link: String)
case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl?)
case downloadFailed(totalBytes: Int64, link: String, archivePath: URL)
case archiveImport(archivePath: String)
case passphraseEntering(passphrase: String)
case migration(passphrase: String)
}
private enum MigrateFromAnotherDeviceViewAlert: Identifiable {
case chatImportedWithErrors(title: LocalizedStringKey = "Chat database imported",
text: LocalizedStringKey = "Some non-fatal errors occurred during import - you may see Chat console for more details.")
case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.")
case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation")
case keychainError(_ title: LocalizedStringKey = "Keychain error")
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case .chatImportedWithErrors: return "chatImportedWithErrors"
case .wrongPassphrase: return "wrongPassphrase"
case .invalidConfirmation: return "invalidConfirmation"
case .keychainError: return "keychainError"
case let .databaseError(title, message): return "\(title) \(message)"
case let .unknownError(title, message): return "\(title) \(message)"
case let .error(title, _): return "error \(title)"
}
}
}
struct MigrateFromAnotherDevice: View {
@EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@State private var migrationState: MigrationState = .pasteOrScanLink(link: "")
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateFromAnotherDeviceViewAlert?
private let tempDatabaseUrl = urlForTemporaryDatabase()
@State private var chatReceiver: MigrationChatReceiver? = nil
@State private var backDisabled: Bool = false
@State private var showQRCodeScanner: Bool = true
var body: some View {
VStack {
switch migrationState {
case let .pasteOrScanLink(link):
pasteOrScanLinkView(link)
case let .linkDownloading(link):
linkDownloadingView(link)
case let .downloadProgress(downloaded, total, _, link, archivePath, _):
downloadProgressView(downloaded, totalBytes: total, link, archivePath)
case let .downloadFailed(total, link, archivePath):
downloadFailedView(totalBytes: total, link, archivePath)
case let .archiveImport(archivePath):
archiveImportView(archivePath)
case let .passphraseEntering(passphrase):
PassphraseEnteringView(migrationState: $migrationState, currentKey: passphrase, alert: $alert)
case let .migration(passphrase):
migrationView(passphrase)
}
}
.modifier(BackButton(label: "Back") {
if !backDisabled {
dismiss()
}
})
.onChange(of: migrationState) { state in
backDisabled = switch migrationState {
case .passphraseEntering: true
case .migration: true
default: false
}
}
.onDisappear {
Task {
if case let .downloadProgress(_, _, fileId, _, _, ctrl) = migrationState, let ctrl {
await stopArchiveDownloading(fileId, ctrl)
}
chatReceiver?.stop()
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
}
}
.alert(item: $alert) { alert in
switch alert {
case let .chatImportedWithErrors(title, text):
return Alert(title: Text(title), message: Text(text))
case let .wrongPassphrase(title, message):
return Alert(title: Text(title), message: Text(message))
case let .invalidConfirmation(title):
return Alert(title: Text(title))
case let .keychainError(title):
return Alert(title: Text(title))
case let .databaseError(title, message):
return Alert(title: Text(title), message: Text(message))
case let .unknownError(title, message):
return Alert(title: Text(title), message: Text(message))
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
}
.interactiveDismissDisabled(backDisabled)
}
private func pasteOrScanLinkView(_ link: String) -> some View {
ZStack {
List {
Section("Paste link to an archive") {
pasteLinkView()
}
Section("Or scan QR code") {
ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in
switch resp {
case let .success(r):
let link = r.string
if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) {
migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces))
} else {
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
}
}
}
}
}
}
private func pasteLinkView() -> some View {
Button {
if let str = UIPasteboard.general.string {
if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) {
migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces))
} else {
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
}
}
} label: {
Text("Tap to paste link")
}
.disabled(!ChatModel.shared.pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
}
private func linkDownloadingView(_ link: String) -> some View {
ZStack {
List {
Section {} header: {
Text("Downloading link details…")
}
}
progressView()
}
.onAppear {
downloadLinkDetails(link)
}
}
private func downloadProgressView(_ downloadedBytes: Int64, totalBytes: Int64, _ link: String, _ archivePath: URL) -> some View {
ZStack {
List {
Section {} header: {
Text("Downloading archive…")
}
}
let ratio = Float(downloadedBytes) / Float(totalBytes)
largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded")
}
}
private func downloadFailedView(totalBytes: Int64, _ link: String, _ archivePath: URL) -> some View {
List {
Section {
Button(action: {
migrationState = .downloadProgress(downloadedBytes: 0, totalBytes: totalBytes, fileId: 0, link: link, archivePath: archivePath, ctrl: nil)
}) {
settingsRow("tray.and.arrow.down") {
Text("Repeat download").foregroundColor(.accentColor)
}
}
} header: {
Text("Download failed")
} footer: {
Text("You can give another try")
.font(.callout)
}
}
.onAppear {
chatReceiver?.stop()
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
}
}
private func archiveImportView(_ archivePath: String) -> some View {
ZStack {
List {
Section {} header: {
Text("Importing archive…")
}
}
progressView()
}
.onAppear {
importArchive(archivePath)
}
}
private func migrationView(_ passphrase: String) -> some View {
ZStack {
List {
Section {} header: {
Text("Migrating…")
}
}
progressView()
}
.onAppear {
startChat(passphrase)
}
}
private func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View {
ZStack {
VStack {
Text(description)
.font(.title3)
.hidden()
Text(title)
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text(description)
.font(.title3)
}
Circle()
.trim(from: 0, to: CGFloat(value))
.stroke(
Color.accentColor,
style: StrokeStyle(lineWidth: 30)
)
.rotationEffect(.degrees(-90))
.animation(.linear, value: value)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
}
private func downloadLinkDetails(_ link: String) {
let archiveTime = Date.now
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
let archiveName = "simplex-chat.\(ts).zip"
let archivePath = getMigrationTempFilesDirectory().appendingPathComponent(archiveName)
startDownloading(0, link, archivePath)
}
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
showErrorOnMigrationIfNeeded(status, $alert)
do {
if let ctrl, let user = try startChatWithTemporaryDatabase(ctrl: ctrl) {
return (ctrl, user)
}
} catch let error {
logger.error("Error while starting chat in temporary database: \(error.localizedDescription)")
}
return nil
}
private func startDownloading(_ totalBytes: Int64, _ link: String, _ archivePath: URL) {
Task {
guard let ctrlAndUser = initTemporaryDatabase() else {
return migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
}
let (ctrl, user) = ctrlAndUser
chatReceiver = MigrationChatReceiver(ctrl: ctrl) { msg in
Task {
await TerminalItems.shared.add(.resp(.now, msg))
}
logger.debug("processReceivedMsg: \(msg.responseType)")
await MainActor.run {
switch msg {
case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
case .rcvStandaloneFileComplete:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .archiveImport(archivePath: archivePath.path)
}
default:
logger.debug("unsupported event: \(msg.responseType)")
}
}
}
chatReceiver?.start()
let (res, error) = await downloadStandaloneFile(user: user, url: link, file: CryptoFile.plain(archivePath.lastPathComponent), ctrl: ctrl)
if res == nil {
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
return alert = .error(title: "Error downloading the archive", error: error ?? "")
}
}
}
private func importArchive(_ archivePath: String) {
Task {
do {
try await apiDeleteStorage()
do {
let config = ArchiveConfig(archivePath: archivePath)
let archiveErrors = try await apiImportArchive(config: config)
if !archiveErrors.isEmpty {
alert = .chatImportedWithErrors()
}
migrationState = .passphraseEntering(passphrase: "")
} catch let error {
alert = .error(title: "Error importing chat database", error: responseError(error))
}
} catch let error {
alert = .error(title: "Error deleting chat database", error: responseError(error))
}
}
}
private func stopArchiveDownloading(_ fileId: Int64, _ ctrl: chat_ctrl) async {
_ = await apiCancelFile(fileId: fileId, ctrl: ctrl)
}
private func cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
Task {
await stopArchiveDownloading(fileId, ctrl)
await MainActor.run {
dismiss()
}
}
}
private func startChat(_ passphrase: String) {
_ = kcDatabasePassword.set(passphrase)
storeDBPassphraseGroupDefault.set(true)
initialRandomDBPassphraseGroupDefault.set(false)
AppChatState.shared.set(.active)
Task {
do {
// resetChatCtrl()
try initializeChat(start: true, confirmStart: false, dbKey: passphrase, refreshInvitations: true)
await MainActor.run {
hideView()
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Notify another device")
}
} catch let error {
hideView()
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
}
}
}
private func hideView() {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
dismiss()
}
private func strHasSimplexFileLink(_ text: String) -> Bool {
text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file")
}
private static func urlForTemporaryDatabase() -> URL {
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
}
}
private struct PassphraseEnteringView: View {
@Binding var migrationState: MigrationState
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State var currentKey: String
@State private var verifyingPassphrase: Bool = false
@Binding var alert: MigrateFromAnotherDeviceViewAlert?
var body: some View {
ZStack {
List {
Section {
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
Button(action: {
verifyingPassphrase = true
hideKeyboard()
Task {
let (status, ctrl) = chatInitTemporaryDatabase(url: getAppDatabasePath(), key: currentKey)
let success = switch status {
case .ok, .invalidConfirmation: true
default: false
}
if success {
// if let ctrl {
// chat_close_store(ctrl)
// }
applyChatCtrl(ctrl: ctrl, result: (currentKey != "", status))
migrationState = .migration(passphrase: currentKey)
} else {
showErrorOnMigrationIfNeeded(status, $alert)
}
verifyingPassphrase = false
}
}) {
settingsRow("key", color: .secondary) {
Text("Open chat")
}
}
} header: {
Text("Enter passphrase")
} footer: {
Text("Passphrase will be stored on device in Keychain. It's required for notifications to work. You can change it later in settings")
.font(.callout)
}
}
if verifyingPassphrase {
progressView()
}
}
}
}
private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding<MigrateFromAnotherDeviceViewAlert?>) {
switch status {
case .invalidConfirmation:
alert.wrappedValue = .invalidConfirmation()
case .errorNotADatabase:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
alert.wrappedValue = .unknownError(message: error)
case .errorMigration: ()
case .ok: ()
}
}
private func progressView() -> some View {
VStack {
ProgressView().scaleEffect(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity )
}
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let processReceivedMsg: (ChatResponse) async -> Void
private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true
init(ctrl: chat_ctrl, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
self.ctrl = ctrl
self.processReceivedMsg = processReceivedMsg
}
func start() {
logger.debug("MigrationChatReceiver.start")
receiveMessages = true
if receiveLoop != nil { return }
receiveLoop = Task { await receiveMsgLoop() }
}
func receiveMsgLoop() async {
// TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) {
await processReceivedMsg(msg)
}
if self.receiveMessages {
_ = try? await Task.sleep(nanoseconds: 7_500_000)
await receiveMsgLoop()
}
}
func stop() {
logger.debug("MigrationChatReceiver.stop")
receiveMessages = false
receiveLoop?.cancel()
receiveLoop = nil
chat_close_store(ctrl)
}
}
struct MigrateFromAnotherDevice_Previews: PreviewProvider {
static var previews: some View {
MigrateFromAnotherDevice()
}
}

View File

@@ -0,0 +1,670 @@
//
// MigrateToAnotherDevice.swift
// SimpleX (iOS)
//
// Created by Avently on 14.02.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
private enum MigrationState: Equatable {
case initial
case chatStopInProgress
case chatStopFailed(reason: String)
case passphraseNotSet
case passphraseConfirmation
case uploadConfirmation
case archiving
case uploadProgress(uploadedBytes: Int64, totalBytes: Int64, fileId: Int64, archivePath: URL, ctrl: chat_ctrl?)
case uploadFailed(totalBytes: Int64, archivePath: URL)
case linkCreation(totalBytes: Int64)
case linkShown(fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl)
case finished
}
private enum MigrateToAnotherDeviceViewAlert: Identifiable {
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.")
case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation")
case keychainError(_ title: LocalizedStringKey = "Keychain error")
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case let .deleteChat(title, text): return "\(title) \(text)"
case let .startChat(title, text): return "\(title) \(text)"
case .wrongPassphrase: return "wrongPassphrase"
case .invalidConfirmation: return "invalidConfirmation"
case .keychainError: return "keychainError"
case let .databaseError(title, message): return "\(title) \(message)"
case let .unknownError(title, message): return "\(title) \(message)"
case let .error(title, _): return "error \(title)"
}
}
}
struct MigrateToAnotherDevice: View {
@EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var showSettings: Bool
@State private var migrationState: MigrationState = .initial
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@AppStorage(GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE, store: groupDefaults) private var initialRandomDBPassphrase: Bool = false
@State private var alert: MigrateToAnotherDeviceViewAlert?
@State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var chatWasStoppedInitially: Bool = true
private let tempDatabaseUrl = urlForTemporaryDatabase()
@State private var chatReceiver: MigrationChatReceiver? = nil
@State private var backDisabled: Bool = false
var body: some View {
if authorized {
migrateView()
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
}
}
private func runAuth() { authorize(NSLocalizedString("Open migration to another device", comment: "authentication reason"), $authorized) }
func migrateView() -> some View {
VStack {
switch migrationState {
case .initial: EmptyView()
case .chatStopInProgress:
chatStopInProgressView()
case let .chatStopFailed(reason):
chatStopFailedView(reason)
case .passphraseNotSet:
passphraseNotSetView()
case .passphraseConfirmation:
PassphraseConfirmationView(migrationState: $migrationState, alert: $alert)
case .uploadConfirmation:
uploadConfirmationView()
case .archiving:
archivingView()
case let .uploadProgress(uploaded, total, _, archivePath, _):
uploadProgressView(uploaded, totalBytes: total, archivePath)
case let .uploadFailed(total, archivePath):
uploadFailedView(totalBytes: total, archivePath)
case let .linkCreation(totalBytes):
linkCreationView(totalBytes)
case let .linkShown(fileId, link, archivePath, ctrl):
linkView(fileId, link, archivePath, ctrl)
case .finished:
finishedView()
}
}
.modifier(BackButton(label: "Back") {
if !backDisabled {
dismiss()
}
})
.onChange(of: migrationState) { state in
backDisabled = switch migrationState {
case .linkCreation: true
case .linkShown: true
default: false
}
}
.onAppear {
if case .initial = migrationState {
if m.chatRunning == false {
migrationState = initialRandomDBPassphrase ? .passphraseNotSet : .passphraseConfirmation
chatWasStoppedInitially = true
} else {
migrationState = .chatStopInProgress
chatWasStoppedInitially = false
stopChat()
}
}
}
.onDisappear {
if case .linkShown = migrationState {} else if case .finished = migrationState {} else if !chatWasStoppedInitially {
Task {
AppChatState.shared.set(.active)
try? startChat(refreshInvitations: true)
}
}
Task {
if case let .uploadProgress(_, _, fileId, _, ctrl) = migrationState, let ctrl {
await cancelUploadedAchive(fileId, ctrl)
}
chatReceiver?.stop()
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
}
}
.alert(item: $alert) { alert in
switch alert {
case let .startChat(title, text):
return Alert(
title: Text(title),
message: Text(text),
primaryButton: .destructive(Text("Start chat")) {
startChatAndDismiss()
},
secondaryButton: .cancel()
)
case let .deleteChat(title, text):
return Alert(
title: Text(title),
message: Text(text),
primaryButton: .destructive(Text("Delete")) {
deleteChatAndDismiss()
},
secondaryButton: .cancel()
)
case let .wrongPassphrase(title, message):
return Alert(title: Text(title), message: Text(message))
case let .invalidConfirmation(title):
return Alert(title: Text(title))
case let .keychainError(title):
return Alert(title: Text(title))
case let .databaseError(title, message):
return Alert(title: Text(title), message: Text(message))
case let .unknownError(title, message):
return Alert(title: Text(title), message: Text(message))
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
}
}
.interactiveDismissDisabled(backDisabled)
}
private func chatStopInProgressView() -> some View {
ZStack {
List {
Section {} header: {
Text("Stopping chat")
}
}
progressView()
}
}
private func chatStopFailedView(_ reason: String) -> some View {
Section {
Text(reason)
Button(action: stopChat) {
settingsRow("stop.fill") {
Text("Stop chat").foregroundColor(.red)
}
}
} header: {
Text("Error stopping chat")
} footer: {
Text("In order to continue, chat should be stopped")
.font(.callout)
}
}
private func passphraseNotSetView() -> some View {
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
.onChange(of: initialRandomDBPassphrase) { initial in
if !initial {
migrationState = .uploadConfirmation
}
}
}
private func uploadConfirmationView() -> some View {
List {
Section {
Button(action: { migrationState = .archiving }) {
settingsRow("tray.and.arrow.up") {
Text("Archive and upload").foregroundColor(.accentColor)
}
}
} header: {
Text("Confirm upload")
} footer: {
Text("All your contacts, conversations and files will be archived and uploaded as encrypted file to configured XFTP relays")
.font(.callout)
}
}
}
private func archivingView() -> some View {
ZStack {
List {
Section {} header: {
Text("Archiving database…")
}
}
progressView()
}
.onAppear {
exportArchive()
}
}
private func uploadProgressView(_ uploadedBytes: Int64, totalBytes: Int64, _ archivePath: URL) -> some View {
ZStack {
List {
Section {} header: {
Text("Uploading archive…")
}
}
let ratio = Float(uploadedBytes) / Float(totalBytes)
largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded")
}
.onAppear {
startUploading(totalBytes, archivePath)
}
}
private func uploadFailedView(totalBytes: Int64, _ archivePath: URL) -> some View {
List {
Section {
Button(action: {
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
}) {
settingsRow("tray.and.arrow.up") {
Text("Repeat upload").foregroundColor(.accentColor)
}
}
} header: {
Text("Upload failed")
} footer: {
Text("You can give another try")
.font(.callout)
}
}
.onAppear {
chatReceiver?.stop()
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
}
}
private func linkCreationView(_ totalBytes: Int64) -> some View {
ZStack {
List {
Section {} header: {
Text("Creating archive link…")
}
}
progressView()
}
}
private func linkView(_ fileId: Int64, _ link: String, _ archivePath: URL, _ ctrl: chat_ctrl) -> some View {
List {
Section {
Button(action: { cancelMigration(fileId, ctrl) }) {
settingsRow("multiply") {
Text("Cancel migration").foregroundColor(.red)
}
}
Button(action: { finishMigration(fileId, ctrl) }) {
settingsRow("checkmark") {
Text("Finalize migration").foregroundColor(.accentColor)
}
}
} footer: {
Text("Make sure you made the migration before going forward")
.font(.callout)
}
Section {
SimpleXLinkQRCode(uri: link)
.frame(maxWidth: .infinity)
shareLinkButton(link)
} header: {
Text("Link to uploaded archive")
} footer: {
Text("Choose Migrate from another device on your new device and scan QR code")
.font(.callout)
}
}
}
private func finishedView() -> some View {
List {
Section {
Button(action: { alert = .deleteChat() }) {
settingsRow("trash.fill") {
Text("Delete database from this device").foregroundColor(.accentColor)
}
}
Button(action: { alert = .startChat() }) {
settingsRow("play.fill") {
Text("Start chat").foregroundColor(.red)
}
}
} header: {
Text("Migration complete")
} footer: {
Text("You should not use the same database on two devices")
.font(.callout)
}
}
}
private func shareLinkButton(_ link: String) -> some View {
Button {
showShareSheet(items: [simplexChatLink(link)])
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
}
private func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View {
ZStack {
VStack {
Text(description)
.font(.title3)
.hidden()
Text(title)
.font(.system(size: 60))
.foregroundColor(.accentColor)
Text(description)
.font(.title3)
}
Circle()
.trim(from: 0, to: CGFloat(value))
.stroke(
Color.accentColor,
style: StrokeStyle(lineWidth: 30)
)
.rotationEffect(.degrees(-90))
.animation(.linear, value: value)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
}
private func stopChat() {
Task {
do {
try await stopChatAsync()
await MainActor.run {
migrationState = initialRandomDBPassphraseGroupDefault.get() ? .passphraseNotSet : .passphraseConfirmation
}
} catch let e {
await MainActor.run {
migrationState = .chatStopFailed(reason: e.localizedDescription)
}
}
}
}
private func exportArchive() {
Task {
do {
try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true)
let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory())
if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path),
let totalBytes = attrs[.size] as? Int64 {
await MainActor.run {
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
}
} else {
await MainActor.run {
alert = .error(title: "Exported file doesn't exist")
migrationState = .uploadConfirmation
}
}
} catch let error {
await MainActor.run {
alert = .error(title: "Error exporting chat database", error: responseError(error))
migrationState = .uploadConfirmation
}
}
}
}
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
showErrorOnMigrationIfNeeded(status, $alert)
do {
if let ctrl, let user = try startChatWithTemporaryDatabase(ctrl: ctrl) {
return (ctrl, user)
}
} catch let error {
logger.error("Error while starting chat in temporary database: \(error.localizedDescription)")
}
return nil
}
private func startUploading(_ totalBytes: Int64, _ archivePath: URL) {
Task {
guard let ctrlAndUser = initTemporaryDatabase() else {
return migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
}
let (ctrl, user) = ctrlAndUser
chatReceiver = MigrationChatReceiver(ctrl: ctrl) { msg in
Task {
await TerminalItems.shared.add(.resp(.now, msg))
}
logger.debug("processReceivedMsg: \(msg.responseType)")
await MainActor.run {
switch msg {
case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize):
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
}
case let .sndFileRedirectStartXFTP(_, fileTransferMeta, _):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkCreation(totalBytes: fileTransferMeta.fileSize)
}
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: rcvURIs[0], archivePath: archivePath, ctrl: ctrl)
}
default:
logger.debug("unsupported event: \(msg.responseType)")
}
}
}
chatReceiver?.start()
let (res, error) = await uploadStandaloneFile(user: user, file: CryptoFile.plain(archivePath.lastPathComponent), ctrl: ctrl)
guard let res = res else {
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
return alert = .error(title: "Error uploading the archive", error: error ?? "")
}
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: res.fileSize, fileId: res.fileId, archivePath: archivePath, ctrl: ctrl)
}
}
private func cancelUploadedAchive(_ fileId: Int64, _ ctrl: chat_ctrl) async {
_ = await apiCancelFile(fileId: fileId, ctrl: ctrl)
}
private func cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
Task {
await cancelUploadedAchive(fileId, ctrl)
await MainActor.run {
if !chatWasStoppedInitially {
startChatAndDismiss()
} else {
dismiss()
}
}
}
}
private func finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
Task {
await cancelUploadedAchive(fileId, ctrl)
await MainActor.run {
migrationState = .finished
}
}
}
private func deleteChatAndDismiss() {
Task {
do {
try await deleteChatAsync()
m.chatDbChanged = true
m.chatInitialized = false
showSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resetChatCtrl()
do {
try initializeChat(start: false)
m.chatDbChanged = false
AppChatState.shared.set(.active)
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
}
dismiss()
} catch let error {
alert = .error(title: "Error deleting database", error: responseError(error))
}
}
}
private func startChatAndDismiss() {
Task {
AppChatState.shared.set(.active)
try? startChat(refreshInvitations: true)
dismiss()
}
}
private static func urlForTemporaryDatabase() -> URL {
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
}
}
private struct PassphraseConfirmationView: View {
@Binding var migrationState: MigrationState
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var currentKey: String = ""
@State private var verifyingPassphrase: Bool = false
@Binding var alert: MigrateToAnotherDeviceViewAlert?
var body: some View {
ZStack {
List {
chatStoppedView()
Section {
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
Button(action: {
verifyingPassphrase = true
hideKeyboard()
Task {
await verifyDatabasePassphrase(currentKey)
verifyingPassphrase = false
}
}) {
settingsRow(useKeychain ? "key" : "lock", color: .secondary) {
Text("Verify passphrase")
}
}
} header: {
Text("Verify database passphrase to migrate it")
} footer: {
Text("Make sure you remember database passphrase before migrating")
.font(.callout)
}
}
if verifyingPassphrase {
progressView()
}
}
}
private func verifyDatabasePassphrase(_ dbKey: String) async {
do {
try await testStorageEncryption(key: dbKey)
migrationState = .uploadConfirmation
} catch {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
}
}
}
private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding<MigrateToAnotherDeviceViewAlert?>) {
switch status {
case .invalidConfirmation:
alert.wrappedValue = .invalidConfirmation()
case .errorNotADatabase:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
alert.wrappedValue = .unknownError(message: error)
case .errorMigration: ()
case .ok: ()
}
}
private func progressView() -> some View {
VStack {
ProgressView().scaleEffect(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity )
}
func chatStoppedView() -> some View {
settingsRow("exclamationmark.octagon.fill", color: .red) {
Text("Chat is stopped")
}
}
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let processReceivedMsg: (ChatResponse) async -> Void
private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true
init(ctrl: chat_ctrl, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
self.ctrl = ctrl
self.processReceivedMsg = processReceivedMsg
}
func start() {
logger.debug("MigrationChatReceiver.start")
receiveMessages = true
if receiveLoop != nil { return }
receiveLoop = Task { await receiveMsgLoop() }
}
func receiveMsgLoop() async {
// TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) {
await processReceivedMsg(msg)
}
if self.receiveMessages {
_ = try? await Task.sleep(nanoseconds: 7_500_000)
await receiveMsgLoop()
}
}
func stop() {
logger.debug("MigrationChatReceiver.stop")
receiveMessages = false
receiveLoop?.cancel()
receiveLoop = nil
chat_close_store(ctrl)
}
}
struct MigrateToAnotherDevice_Previews: PreviewProvider {
static var previews: some View {
MigrateToAnotherDevice(showSettings: Binding.constant(true))
}
}

View File

@@ -86,7 +86,7 @@ struct NewChatView: View {
} }
} }
if case .connect = selection { if case .connect = selection {
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
.transition(.move(edge: .trailing)) .transition(.move(edge: .trailing))
} }
} }
@@ -284,8 +284,7 @@ private struct InviteView: View {
private struct ConnectView: View { private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var showQRCodeScanner = false @Binding var showQRCodeScanner: Bool
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
@Binding var pastedLink: String @Binding var pastedLink: String
@Binding var alert: NewChatViewAlert? @Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet? @State private var sheet: PlanAndConnectActionSheet?
@@ -295,32 +294,13 @@ private struct ConnectView: View {
Section("Paste the link you received") { Section("Paste the link you received") {
pasteLinkView() pasteLinkView()
} }
Section("Or scan QR code") {
scanCodeView() ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
}
} }
.actionSheet(item: $sheet) { s in .actionSheet(item: $sheet) { s in
planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" }) planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
} }
.onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status
if showQRCodeScanner {
switch status {
case .notDetermined: askCameraAuthorization()
case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false
case .authorized: ()
@unknown default: askCameraAuthorization()
}
}
}
}
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
if allowed { cb?() }
}
} }
@ViewBuilder private func pasteLinkView() -> some View { @ViewBuilder private func pasteLinkView() -> some View {
@@ -351,8 +331,45 @@ private struct ConnectView: View {
} }
} }
private func scanCodeView() -> some View { private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
Section("Or scan QR code") { switch resp {
case let .success(r):
let link = r.string
if strIsSimplexLink(r.string) {
connect(link)
} else {
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
id: "processQRCode: code is not a SimpleX link"
))
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
id: "processQRCode: failure"
))
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
}
}
struct ScannerInView: View {
@Binding var showQRCodeScanner: Bool
let processQRCode: (_ resp: Result<ScanResult, ScanError>) -> Void
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
var body: some View {
Group {
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus { if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode) CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
@@ -396,37 +413,26 @@ private struct ConnectView: View {
.disabled(cameraAuthorizationStatus == .restricted) .disabled(cameraAuthorizationStatus == .restricted)
} }
} }
} .onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video)
private func processQRCode(_ resp: Result<ScanResult, ScanError>) { cameraAuthorizationStatus = status
switch resp { if showQRCodeScanner {
case let .success(r): switch status {
let link = r.string case .notDetermined: askCameraAuthorization()
if strIsSimplexLink(r.string) { case .restricted: showQRCodeScanner = false
connect(link) case .denied: showQRCodeScanner = false
} else { case .authorized: ()
alert = .newChatSomeAlert(alert: .someAlert( @unknown default: askCameraAuthorization()
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."), }
id: "processQRCode: code is not a SimpleX link"
))
} }
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
id: "processQRCode: failure"
))
} }
} }
private func connect(_ link: String) { func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
planAndConnect( AVCaptureDevice.requestAccess(for: .video) { allowed in
link, cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
showAlert: { alert = .planAndConnectAlert(alert: $0) }, if allowed { cb?() }
showActionSheet: { sheet = $0 }, }
dismiss: true,
incognito: nil
)
} }
} }

View File

@@ -37,7 +37,7 @@ struct HowItWorks: View {
Spacer() Spacer()
if onboarding { if onboarding {
OnboardingActionButton() OnboardingActionButton(hideMigrate: true)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }

View File

@@ -42,7 +42,7 @@ struct SimpleXInfo: View {
Spacer() Spacer()
if onboarding { if onboarding {
OnboardingActionButton() OnboardingActionButton(hideMigrate: false)
Spacer() Spacer()
} }
@@ -87,10 +87,28 @@ struct SimpleXInfo: View {
struct OnboardingActionButton: View { struct OnboardingActionButton: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
let hideMigrate: Bool
@State private var migrateFromAnotherDevice: Bool = false
var body: some View { var body: some View {
if m.currentUser == nil { if m.currentUser == nil {
actionButton("Create your profile", onboarding: .step2_CreateProfile) actionButton("Create your profile", onboarding: .step2_CreateProfile)
if !hideMigrate {
actionButton("Migrate from another device") {
migrateFromAnotherDevice = true
}
.sheet(isPresented: $migrateFromAnotherDevice) {
VStack(alignment: .leading) {
Text("Migrate here")
.font(.largeTitle)
.padding([.leading, .top, .trailing])
.padding(.top)
MigrateFromAnotherDevice()
}
.background(Color(uiColor: .tertiarySystemGroupedBackground))
}
}
} else { } else {
actionButton("Make a private connection", onboarding: .onboardingComplete) actionButton("Make a private connection", onboarding: .onboardingComplete)
} }
@@ -111,6 +129,21 @@ struct OnboardingActionButton: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.bottom) .padding(.bottom)
} }
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button {
withAnimation {
action()
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
}
}
.frame(maxWidth: .infinity)
.padding(.bottom)
}
} }
struct SimpleXInfo_Previews: PreviewProvider { struct SimpleXInfo_Previews: PreviewProvider {

View File

@@ -163,48 +163,57 @@ struct SettingsView: View {
NavigationView { NavigationView {
List { List {
Section("You") { Section("You") {
if let user = user { Group {
if let user = user {
NavigationLink {
UserProfile()
.navigationTitle("Your current profile")
} label: {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
}
NavigationLink { NavigationLink {
UserProfile() UserProfilesView(showSettings: $showSettings)
.navigationTitle("Your current profile")
} label: { } label: {
ProfilePreview(profileOf: user) settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
.padding(.leading, -8) }
if let user = user {
NavigationLink {
UserAddressView(shareViaProfile: user.addressShared)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode") { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
} label: {
settingsRow("switch.2") { Text("Chat preferences") }
}
}
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer") { Text("Use from desktop") }
} }
} }
.disabled(chatModel.chatRunning != true)
NavigationLink { NavigationLink {
UserProfilesView(showSettings: $showSettings) MigrateToAnotherDevice(showSettings: $showSettings)
.navigationTitle("Migrate device")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } settingsRow("tray.and.arrow.up") { Text("Migrate to another device") }
}
if let user = user {
NavigationLink {
UserAddressView(shareViaProfile: user.addressShared)
.navigationTitle("SimpleX address")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode") { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
} label: {
settingsRow("switch.2") { Text("Chat preferences") }
}
}
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer") { Text("Use from desktop") }
} }
} }
.disabled(chatModel.chatRunning != true)
Section("Settings") { Section("Settings") {
NavigationLink { NavigationLink {
NotificationsView() NotificationsView()

View File

@@ -640,7 +640,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
cleanupDirectFile(aChatItem) cleanupDirectFile(aChatItem)
return nil return nil
case let .sndFileRcvCancelled(_, aChatItem, _): case let .sndFileRcvCancelled(_, aChatItem, _):
cleanupDirectFile(aChatItem) if let aChatItem = aChatItem {
cleanupDirectFile(aChatItem)
}
return nil return nil
case let .sndFileCompleteXFTP(_, aChatItem, _): case let .sndFileCompleteXFTP(_, aChatItem, _):
cleanupFile(aChatItem) cleanupFile(aChatItem)

View File

@@ -185,6 +185,8 @@
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; };
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
@@ -473,6 +475,8 @@
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; }; 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = "<group>"; };
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
@@ -553,6 +557,7 @@
5CB924DD27A8622200ACCCDD /* NewChat */, 5CB924DD27A8622200ACCCDD /* NewChat */,
5CFA59C22860B04D00863A68 /* Database */, 5CFA59C22860B04D00863A68 /* Database */,
5CB634AB29E46CDB0066AD6B /* LocalAuth */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */,
8C7D94982B8894D300B7B9E1 /* Migration */,
5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
5CB924DF27A8678B00ACCCDD /* UserSettings */, 5CB924DF27A8678B00ACCCDD /* UserSettings */,
5C2E261127A30FEA00F70299 /* TerminalView.swift */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */,
@@ -893,6 +898,15 @@
path = Group; path = Group;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8C7D94982B8894D300B7B9E1 /* Migration */ = {
isa = PBXGroup;
children = (
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */,
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */,
);
path = Migration;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */ /* Begin PBXHeadersBuildPhase section */
@@ -1124,6 +1138,7 @@
5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */,
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */, 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
@@ -1220,6 +1235,7 @@
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,

View File

@@ -54,6 +54,18 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
return result return result
} }
public func chatInitTemporaryDatabase(url: URL, key: String? = nil) -> (DBMigrationResult, chat_ctrl?) {
let dbPath = url.path
let dbKey = key ?? randomDatabasePassword()
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
var temporaryController: chat_ctrl? = nil
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)!
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)!
return (dbMigrationResult(fromCString(cjson)), temporaryController)
}
public func chatCloseStore() { public func chatCloseStore() {
let err = fromCString(chat_close_store(getChatCtrl())) let err = fromCString(chat_close_store(getChatCtrl()))
if err != "" { if err != "" {
@@ -73,17 +85,22 @@ public func resetChatCtrl() {
migrationResult = nil migrationResult = nil
} }
public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { public func applyChatCtrl(ctrl: chat_ctrl?, result: (Bool, DBMigrationResult)) {
chatController = ctrl
migrationResult = result
}
public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
var c = cmd.cmdString.cString(using: .utf8)! var c = cmd.cmdString.cString(using: .utf8)!
let cjson = chat_send_cmd(getChatCtrl(), &c)! let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)!
return chatResponse(fromCString(cjson)) return chatResponse(fromCString(cjson))
} }
// in microseconds // in microseconds
let MESSAGE_TIMEOUT: Int32 = 15_000_000 let MESSAGE_TIMEOUT: Int32 = 15_000_000
public func recvSimpleXMsg() -> ChatResponse? { public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil) -> ChatResponse? {
if let cjson = chat_recv_msg_wait(getChatCtrl(), MESSAGE_TIMEOUT) { if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), MESSAGE_TIMEOUT) {
let s = fromCString(cjson) let s = fromCString(cjson)
return s == "" ? nil : chatResponse(s) return s == "" ? nil : chatResponse(s)
} }

View File

@@ -36,6 +36,7 @@ public enum ChatCommand {
case apiImportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage case apiDeleteStorage
case apiStorageEncryption(config: DBEncryptionConfig) case apiStorageEncryption(config: DBEncryptionConfig)
case testStorageEncryption(key: String)
case apiGetChats(userId: Int64) case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
@@ -130,6 +131,8 @@ public enum ChatCommand {
case listRemoteCtrls case listRemoteCtrls
case stopRemoteCtrl case stopRemoteCtrl
case deleteRemoteCtrl(remoteCtrlId: Int64) case deleteRemoteCtrl(remoteCtrlId: Int64)
case apiUploadStandaloneFile(userId: Int64, file: CryptoFile)
case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile)
// misc // misc
case showVersion case showVersion
case string(String) case string(String)
@@ -166,6 +169,7 @@ public enum ChatCommand {
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete" case .apiDeleteStorage: return "/_db delete"
case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))"
case let .testStorageEncryption(key): return "/db test key \(key)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)") (search == "" ? "" : " search=\(search)")
@@ -278,6 +282,8 @@ public enum ChatCommand {
case .listRemoteCtrls: return "/list remote ctrls" case .listRemoteCtrls: return "/list remote ctrls"
case .stopRemoteCtrl: return "/stop remote ctrl" case .stopRemoteCtrl: return "/stop remote ctrl"
case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)"
case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)"
case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
case .showVersion: return "/version" case .showVersion: return "/version"
case let .string(str): return str case let .string(str): return str
} }
@@ -310,6 +316,7 @@ public enum ChatCommand {
case .apiImportArchive: return "apiImportArchive" case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage" case .apiDeleteStorage: return "apiDeleteStorage"
case .apiStorageEncryption: return "apiStorageEncryption" case .apiStorageEncryption: return "apiStorageEncryption"
case .testStorageEncryption: return "testStorageEncryption"
case .apiGetChats: return "apiGetChats" case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat" case .apiGetChat: return "apiGetChat"
case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiGetChatItemInfo: return "apiGetChatItemInfo"
@@ -402,6 +409,8 @@ public enum ChatCommand {
case .listRemoteCtrls: return "listRemoteCtrls" case .listRemoteCtrls: return "listRemoteCtrls"
case .stopRemoteCtrl: return "stopRemoteCtrl" case .stopRemoteCtrl: return "stopRemoteCtrl"
case .deleteRemoteCtrl: return "deleteRemoteCtrl" case .deleteRemoteCtrl: return "deleteRemoteCtrl"
case .apiUploadStandaloneFile: return "apiUploadStandaloneFile"
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
case .showVersion: return "showVersion" case .showVersion: return "showVersion"
case .string: return "console command" case .string: return "console command"
} }
@@ -436,6 +445,8 @@ public enum ChatCommand {
return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd))
case let .apiDeleteUser(userId, delSMPQueues, viewPwd): case let .apiDeleteUser(userId, delSMPQueues, viewPwd):
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
case let .testStorageEncryption(key):
return .testStorageEncryption(key: obfuscate(key))
default: return self default: return self
} }
} }
@@ -584,20 +595,27 @@ public enum ChatResponse: Decodable, Error {
// receiving file events // receiving file events
case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer)
case rcvFileStart(user: UserRef, chatItem: AChatItem) case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer)
case rcvFileProgressXFTP(user: UserRef, chatItem: AChatItem, receivedSize: Int64, totalSize: Int64) case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats
case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer)
case rcvFileComplete(user: UserRef, chatItem: AChatItem) case rcvFileComplete(user: UserRef, chatItem: AChatItem)
case rcvFileCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer)
case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer)
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
case rcvFileError(user: UserRef, chatItem: AChatItem) case rcvFileError(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer)
// sending file events // sending file events
case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileCancelled(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
case sndFileRcvCancelled(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
case sndFileProgressXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload
case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used
case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64)
case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta)
case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta)
case sndFileError(user: UserRef, chatItem: AChatItem) case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String])
case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta)
case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta)
// call events // call events
case callInvitation(callInvitation: RcvCallInvitation) case callInvitation(callInvitation: RcvCallInvitation)
case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool)
@@ -735,18 +753,25 @@ public enum ChatResponse: Decodable, Error {
case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" case .newMemberContactReceivedInv: return "newMemberContactReceivedInv"
case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileAccepted: return "rcvFileAccepted"
case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled"
case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated"
case .rcvFileStart: return "rcvFileStart" case .rcvFileStart: return "rcvFileStart"
case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" case .rcvFileProgressXFTP: return "rcvFileProgressXFTP"
case .rcvFileComplete: return "rcvFileComplete" case .rcvFileComplete: return "rcvFileComplete"
case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete"
case .rcvFileCancelled: return "rcvFileCancelled" case .rcvFileCancelled: return "rcvFileCancelled"
case .rcvFileSndCancelled: return "rcvFileSndCancelled" case .rcvFileSndCancelled: return "rcvFileSndCancelled"
case .rcvFileError: return "rcvFileError" case .rcvFileError: return "rcvFileError"
case .sndFileStart: return "sndFileStart" case .sndFileStart: return "sndFileStart"
case .sndFileComplete: return "sndFileComplete" case .sndFileComplete: return "sndFileComplete"
case .sndFileCancelled: return "sndFileCancelled" case .sndFileCancelled: return "sndFileCancelled"
case .sndFileRcvCancelled: return "sndFileRcvCancelled" case .sndStandaloneFileCreated: return "sndStandaloneFileCreated"
case .sndFileStartXFTP: return "sndFileStartXFTP"
case .sndFileProgressXFTP: return "sndFileProgressXFTP" case .sndFileProgressXFTP: return "sndFileProgressXFTP"
case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP"
case .sndFileRcvCancelled: return "sndFileRcvCancelled"
case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" case .sndFileCompleteXFTP: return "sndFileCompleteXFTP"
case .sndStandaloneFileComplete: return "sndStandaloneFileComplete"
case .sndFileCancelledXFTP: return "sndFileCancelledXFTP"
case .sndFileError: return "sndFileError" case .sndFileError: return "sndFileError"
case .callInvitation: return "callInvitation" case .callInvitation: return "callInvitation"
case .callOffer: return "callOffer" case .callOffer: return "callOffer"
@@ -885,19 +910,26 @@ public enum ChatResponse: Decodable, Error {
case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
case .rcvFileAcceptedSndCancelled: return noDetails case .rcvFileAcceptedSndCancelled: return noDetails
case .rcvStandaloneFileCreated: return noDetails
case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem))
case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)")
case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath)
case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem))
case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .rcvFileError(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileError(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem))
case .sndStandaloneFileCreated: return noDetails
case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)")
case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta))
case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileError(u, chatItem): return withUser(u, String(describing: chatItem)) case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count))
case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileError(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .callInvitation(inv): return String(describing: inv) case let .callInvitation(inv): return String(describing: inv)
case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))")
case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))")
@@ -1721,6 +1753,7 @@ public enum StoreError: Decodable {
case fileIdNotFoundBySharedMsgId(sharedMsgId: String) case fileIdNotFoundBySharedMsgId(sharedMsgId: String)
case sndFileNotFoundXFTP(agentSndFileId: String) case sndFileNotFoundXFTP(agentSndFileId: String)
case rcvFileNotFoundXFTP(agentRcvFileId: String) case rcvFileNotFoundXFTP(agentRcvFileId: String)
case extraFileDescrNotFoundXFTP(fileId: Int64)
case connectionNotFound(agentConnId: String) case connectionNotFound(agentConnId: String)
case connectionNotFoundById(connId: Int64) case connectionNotFoundById(connId: Int64)
case connectionNotFoundByMemberId(groupMemberId: Int64) case connectionNotFoundByMemberId(groupMemberId: Int64)

View File

@@ -36,7 +36,7 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt"
public let GROUP_DEFAULT_INCOGNITO = "incognito" public let GROUP_DEFAULT_INCOGNITO = "incognito"
let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase"
let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase"
public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades"
public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled"

View File

@@ -3378,11 +3378,14 @@ public struct SndFileTransfer: Decodable {
} }
public struct RcvFileTransfer: Decodable { public struct RcvFileTransfer: Decodable {
public let fileId: Int64
} }
public struct FileTransferMeta: Decodable { public struct FileTransferMeta: Decodable {
public let fileId: Int64
public let fileName: String
public let filePath: String
public let fileSize: Int64
} }
public enum CICallStatus: String, Decodable { public enum CICallStatus: String, Decodable {

View File

@@ -83,6 +83,7 @@ public func deleteAppDatabaseAndFiles() {
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
try? fm.removeItem(at: getTempFilesDirectory()) try? fm.removeItem(at: getTempFilesDirectory())
try? fm.removeItem(at: getMigrationTempFilesDirectory())
try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
deleteAppFiles() deleteAppFiles()
_ = kcDatabasePassword.remove() _ = kcDatabasePassword.remove()
@@ -183,6 +184,10 @@ public func getTempFilesDirectory() -> URL {
getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) getAppDirectory().appendingPathComponent("temp_files", isDirectory: true)
} }
public func getMigrationTempFilesDirectory() -> URL {
getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true)
}
public func getAppFilesDirectory() -> URL { public func getAppFilesDirectory() -> URL {
getAppDirectory().appendingPathComponent("app_files", isDirectory: true) getAppDirectory().appendingPathComponent("app_files", isDirectory: true)
} }

View File

@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package source-repository-package
type: git type: git
location: https://github.com/simplex-chat/simplexmq.git location: https://github.com/simplex-chat/simplexmq.git
tag: 050a921fbbdf21690cab7765bf6237fdc5a419cb tag: 0d843ea4ce1b26a25b55756bf86d1007629896c5
source-repository-package source-repository-package
type: git type: git

View File

@@ -1,38 +0,0 @@
# Inactive group members (simplified)
[Original doc](./2023-11-21-inactive-group-members.md)
## Problem
Groups traffic is higher than necessary due to sending messages to inactive group members.
## Solution
### Improve connection deletion
- When leaving or deleting group, batch db operations to optimize performance.
- In agent - fix race where connection can be deleted while it has remaining pending messages.
- Current agent logic is to immediately delete connection if it has no rcv queues left.
- Simplest should be to make a smart version of `deleteConn` for this improvement, checking `snd_messages` table for remaining messages, and keep connection around in case there are.
- While this may improve delivery of group leave and delete messages, it may as well have undesirable side effects for other use cases, as any pending messages will be sent prior to deleting connection. For example, user sends several messages on bad network, decides to delete contact, messages are still delivered when user is on good network before deletion, even though this contradicts user's intent and messages hadn't left user's device at the time of deletion. Considering this race when it happens is identical to simply leaving groups by deleting app, or deleting user profile only locally, it may be a bad idea to affect regular contact deletion for this use case.
### Track member inactivity
- Mark members as inactive on QUOTA errors, reset as active on QCONT
- track `group_members.inactive` flag per group member
- on SMP.QUOTA error agent to notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA)
- on receiving QCONT agent to notify client (new event)
- apart from QCONT, reset on any message or receipt
- Don't send to member if inactive
- don't send only content messages (x.msg.new, etc.) and always send messages altering group state?
- or don't send any messages?
- Track number of skipped messages per member and first skipped message
- count `group_members.skipped_msg_cnt`
- only count messages of same types/criteria that are included into history
- track `group_members.skipped_first_shared_msg_id` (only content or including service messages?)
- Send XGrpMsgSkipped before next message
- check `skipped_msg_cnt` > 0 and `skipped_first_shared_msg_id` is not null to only send once, reset after sending
```haskell
XGrpMsgSkipped :: SharedMsgId -> Int64 -> ChatMsgEvent 'Json -- from, count
```

View File

@@ -1,5 +1,5 @@
{ {
"https://github.com/simplex-chat/simplexmq.git"."050a921fbbdf21690cab7765bf6237fdc5a419cb" = "0bc8x3pv3l6wjcfx06yhyydf2amaw5jjax2wcbgbxzrhqz10xf1v"; "https://github.com/simplex-chat/simplexmq.git"."0d843ea4ce1b26a25b55756bf86d1007629896c5" = "0p3mw5kpqhxsjhairx7qaacv33hm11wmbax6jzv2w49nwkcpnbal";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";

View File

@@ -136,7 +136,6 @@ library
Simplex.Chat.Migrations.M20240122_indexes Simplex.Chat.Migrations.M20240122_indexes
Simplex.Chat.Migrations.M20240214_redirect_file_id Simplex.Chat.Migrations.M20240214_redirect_file_id
Simplex.Chat.Migrations.M20240222_app_settings Simplex.Chat.Migrations.M20240222_app_settings
Simplex.Chat.Migrations.M20240226_users_restrict
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared

View File

@@ -939,8 +939,7 @@ processChatCommand' vr = \case
ct <- withStore $ \db -> getContact db user chatId ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
withChatLock "deleteChat direct" . procCmd $ do withChatLock "deleteChat direct" . procCmd $ do
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
when (contactReady ct && contactActive ct && notify) $ when (contactReady ct && contactActive ct && notify) $
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct) contactConnIds <- map aConnId <$> withStore' (\db -> getContactConnections db userId ct)
@@ -963,8 +962,7 @@ processChatCommand' vr = \case
unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
withChatLock "deleteChat group" . procCmd $ do withChatLock "deleteChat group" . procCmd $ do
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel
deleteGroupLinkIfExists user gInfo deleteGroupLinkIfExists user gInfo
deleteMembersConnections user members deleteMembersConnections user members
@@ -975,40 +973,37 @@ processChatCommand' vr = \case
withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members
withStore' $ \db -> deleteGroup db user gInfo withStore' $ \db -> deleteGroup db user gInfo
let contactIds = mapMaybe memberContactId members let contactIds = mapMaybe memberContactId members
(errs1, (errs2, connIds)) <- second unzip . partitionEithers <$> withStoreBatch (\db -> map (deleteUnusedContact db) contactIds) deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds
let errs = errs1 <> mapMaybe (fmap ChatErrorStore) errs2
unless (null errs) $ toView $ CRChatErrors (Just user) errs
deleteAgentConnectionsAsync user $ concat connIds
pure $ CRGroupDeletedUser user gInfo pure $ CRGroupDeletedUser user gInfo
where where
deleteUnusedContact :: DB.Connection -> ContactId -> IO (Either ChatError (Maybe StoreError, [ConnId])) deleteUnusedContact :: ContactId -> m [ConnId]
deleteUnusedContact db contactId = runExceptT . withExceptT ChatErrorStore $ do deleteUnusedContact contactId =
ct <- getContact db user contactId (withStore (\db -> getContact db user contactId) >>= delete)
ifM `catchChatError` (\e -> toView (CRChatError (Just user) e) $> [])
((directOrUsed ct ||) . isJust <$> liftIO (checkContactHasGroups db user ct))
(pure (Nothing, []))
(getConnections ct)
where where
getConnections :: Contact -> ExceptT StoreError IO (Maybe StoreError, [ConnId]) delete ct
getConnections ct = do | directOrUsed ct = pure []
conns <- liftIO $ getContactConnections db userId ct | otherwise =
e_ <- (setContactDeleted db user ct $> Nothing) `catchStoreError` (pure . Just) withStore' (\db -> checkContactHasGroups db user ct) >>= \case
pure (e_, map aConnId conns) Just _ -> pure []
Nothing -> do
conns <- withStore' $ \db -> getContactConnections db userId ct
withStore (\db -> setContactDeleted db user ct)
`catchChatError` (toView . CRChatError (Just user))
pure $ map aConnId conns
CTLocal -> pure $ chatCmdError (Just user) "not supported" CTLocal -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of
CTDirect -> do CTDirect -> do
ct <- withStore $ \db -> getContact db user chatId ct <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
withStore' $ \db -> deleteContactCIs db user ct withStore' $ \db -> deleteContactCIs db user ct
pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct)
CTGroup -> do CTGroup -> do
gInfo <- withStore $ \db -> getGroupInfo db vr user chatId gInfo <- withStore $ \db -> getGroupInfo db vr user chatId
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
withStore' $ \db -> deleteGroupCIs db user gInfo withStore' $ \db -> deleteGroupCIs db user gInfo
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
@@ -1017,7 +1012,7 @@ processChatCommand' vr = \case
nf <- withStore $ \db -> getNoteFolder db user chatId nf <- withStore $ \db -> getNoteFolder db user chatId
filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf
withChatLock "clearChat local" . procCmd $ do withChatLock "clearChat local" . procCmd $ do
deleteFilesLocally filesInfo mapM_ (deleteFile user) filesInfo
withStore' $ \db -> deleteNoteFolderFiles db userId nf withStore' $ \db -> deleteNoteFolderFiles db userId nf
withStore' $ \db -> deleteNoteFolderCIs db user nf withStore' $ \db -> deleteNoteFolderCIs db user nf
pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf)
@@ -1702,9 +1697,7 @@ processChatCommand' vr = \case
pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved}
APILeaveGroup groupId -> withUser $ \user@User {userId} -> do APILeaveGroup groupId -> withUser $ \user@User {userId} -> do
Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId
filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo
withChatLock "leaveGroup" . procCmd $ do withChatLock "leaveGroup" . procCmd $ do
cancelFilesInProgress user filesInfo
(msg, _) <- sendGroupMessage' user gInfo members XGrpLeave (msg, _) <- sendGroupMessage' user gInfo members XGrpLeave
ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft)
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci)
@@ -2358,8 +2351,7 @@ processChatCommand' vr = \case
deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser :: User -> Bool -> m ChatResponse
deleteChatUser user delSMPQueues = do deleteChatUser user delSMPQueues = do
filesInfo <- withStore' (`getUserFileInfo` user) filesInfo <- withStore' (`getUserFileInfo` user)
cancelFilesInProgress user filesInfo forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
deleteFilesLocally filesInfo
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
withStore' (`deleteUserRecord` user) withStore' (`deleteUserRecord` user)
when (activeUser user) $ chatWriteVar currentUser Nothing when (activeUser user) $ chatWriteVar currentUser Nothing
@@ -2567,72 +2559,50 @@ setAllExpireCIFlags b = do
keys <- M.keys <$> readTVar expireFlags keys <- M.keys <$> readTVar expireFlags
forM_ keys $ \k -> TM.insert k b expireFlags forM_ keys $ \k -> TM.insert k b expireFlags
cancelFilesInProgress :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () deleteFilesAndConns :: ChatMonad m => User -> [CIFileInfo] -> m ()
cancelFilesInProgress user filesInfo = do deleteFilesAndConns user filesInfo = do
let filesInfo' = filter (not . fileEnded) filesInfo connIds <- mapM (deleteFile user) filesInfo
(sfs, rfs) <- splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo') deleteAgentConnectionsAsync user $ concat connIds
forM_ rfs $ \RcvFileTransfer {fileId} -> closeFileHandle fileId rcvFiles `catchChatError` \_ -> pure ()
void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs
void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs
let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs
xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs
agentXFTPDeleteSndFilesRemote user xsfIds
agentXFTPDeleteRcvFiles xrfIds
let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs
smpRFConnIds = mapMaybe smpRcvFileConnId rfs
deleteAgentConnectionsAsync user smpSFConnIds
deleteAgentConnectionsAsync user smpRFConnIds
where
fileEnded CIFileInfo {fileStatus} = case fileStatus of
Just (AFS _ status) -> ciFileEnded status
Nothing -> True
getFT :: DB.Connection -> CIFileInfo -> IO (Either ChatError FileTransfer)
getFT db CIFileInfo {fileId} = runExceptT . withExceptT ChatErrorStore $ getFileTransfer db user fileId
updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO ()
updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do
updateFileCancelled db user fileId CIFSSndCancelled
forM_ sfts updateSndFTCancelled
where
updateSndFTCancelled :: SndFileTransfer -> IO ()
updateSndFTCancelled ft = unless (sndFTEnded ft) $ do
updateSndFileStatus db ft FSCancelled
deleteSndFileChunks db ft
updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO ()
updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do
updateFileCancelled db user fileId CIFSRcvCancelled
updateRcvFileStatus db fileId FSCancelled
deleteRcvFileChunks db ft
splitFTTypes :: [Either ChatError FileTransfer] -> ([(FileTransferMeta, [SndFileTransfer])], [RcvFileTransfer])
splitFTTypes = foldr addFT ([], []) . rights
where
addFT f (sfs, rfs) = case f of
FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs)
FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs)
_ -> (sfs, rfs)
smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId
smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline}
| isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId
| otherwise = Nothing
smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId
smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline}
| isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft
| otherwise = Nothing
sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete
deleteFilesLocally :: forall m. ChatMonad m => [CIFileInfo] -> m () deleteFile :: ChatMonad m => User -> CIFileInfo -> m [ConnId]
deleteFilesLocally files = deleteFile user fileInfo = deleteFile' user fileInfo False
withFilesFolder $ \filesFolder ->
liftIO . forM_ files $ \CIFileInfo {filePath} -> deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
mapM_ (delete . (filesFolder </>)) filePath deleteFile' user ciFileInfo@CIFileInfo {filePath} sendCancel = do
aConnIds <- cancelFile' user ciFileInfo sendCancel
forM_ filePath $ \fPath ->
deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
pure aConnIds
deleteFileLocally :: forall m. ChatMonad m => FilePath -> m ()
deleteFileLocally fPath =
withFilesFolder $ \filesFolder -> liftIO $ do
let fsFilePath = filesFolder </> fPath
removeFile fsFilePath `catchAll` \_ ->
removePathForcibly fsFilePath `catchAll_` pure ()
where where
delete :: FilePath -> IO ()
delete fPath =
removeFile fPath `catchAll` \_ ->
removePathForcibly fPath `catchAll_` pure ()
-- perform an action only if filesFolder is set (i.e. on mobile devices) -- perform an action only if filesFolder is set (i.e. on mobile devices)
withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder :: (FilePath -> m ()) -> m ()
withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action
cancelFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId]
cancelFile' user CIFileInfo {fileId, fileStatus} sendCancel =
case fileStatus of
Just fStatus -> cancel' fStatus `catchChatError` (\e -> toView (CRChatError (Just user) e) $> [])
Nothing -> pure []
where
cancel' :: ACIFileStatus -> m [ConnId]
cancel' (AFS dir status) =
if ciFileEnded status
then pure []
else case dir of
SMDSnd -> do
(ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId)
if cancelled then pure [] else cancelSndFile user ftm fts sendCancel
SMDRcv -> do
ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId)
if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft
updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m () updateCallItemStatus :: ChatMonad m => User -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do
aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus aciContent_ <- callStatusItemContent user ct chatItemId receivedStatus
@@ -3196,15 +3166,13 @@ expireChatItems user@User {userId} ttl sync = do
processContact expirationDate ct = do processContact expirationDate ct = do
waitChatStartedAndActivated waitChatStartedAndActivated
filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate filesInfo <- withStoreCtx' (Just "processContact, getContactExpiredFileInfo") $ \db -> getContactExpiredFileInfo db user ct expirationDate
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate withStoreCtx' (Just "processContact, deleteContactExpiredCIs") $ \db -> deleteContactExpiredCIs db user ct expirationDate
processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () processGroup :: UTCTime -> UTCTime -> GroupInfo -> m ()
processGroup expirationDate createdAtCutoff gInfo = do processGroup expirationDate createdAtCutoff gInfo = do
waitChatStartedAndActivated waitChatStartedAndActivated
filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff filesInfo <- withStoreCtx' (Just "processGroup, getGroupExpiredFileInfo") $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
cancelFilesInProgress user filesInfo deleteFilesAndConns user filesInfo
deleteFilesLocally filesInfo
withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff withStoreCtx' (Just "processGroup, deleteGroupExpiredCIs") $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo membersToDelete <- withStoreCtx' (Just "processGroup, getGroupMembersForExpiration") $ \db -> getGroupMembersForExpiration db user gInfo
forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m forM_ membersToDelete $ \m -> withStoreCtx' (Just "processGroup, deleteGroupMember") $ \db -> deleteGroupMember db user m
@@ -5870,7 +5838,7 @@ deleteMembersConnections user members = do
filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $ filter (\Connection {connStatus} -> connStatus /= ConnDeleted) $
mapMaybe (\GroupMember {activeConn} -> activeConn) members mapMaybe (\GroupMember {activeConn} -> activeConn) members
deleteAgentConnectionsAsync user $ map aConnId memberConns deleteAgentConnectionsAsync user $ map aConnId memberConns
void . withStoreBatch' $ \db -> map (\conn -> updateConnectionStatus db conn ConnDeleted) memberConns forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m ()
deleteMemberConnection user GroupMember {activeConn} = do deleteMemberConnection user GroupMember {activeConn} = do
@@ -6185,19 +6153,18 @@ deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedT
gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo)
deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse deleteLocalCI :: (ChatMonad m, MsgDirectionI d) => User -> NoteFolder -> ChatItem 'CTLocal d -> Bool -> Bool -> m ChatResponse
deleteLocalCI user nf ci@ChatItem {file = file_} byUser timed = do deleteLocalCI user nf ci@ChatItem {file} byUser timed = do
forM_ file_ $ \file -> do forM_ file $ \CIFile {fileSource} -> do
let filesInfo = [mkCIFileInfo file] forM_ (CF.filePath <$> fileSource) $ \fPath ->
deleteFilesLocally filesInfo deleteFileLocally fPath `catchChatError` (toView . CRChatError (Just user))
withStore' $ \db -> deleteLocalChatItem db user nf ci withStore' $ \db -> deleteLocalChatItem db user nf ci
pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed pure $ CRChatItemDeleted user (AChatItem SCTLocal msgDirection (LocalChat nf) ci) Nothing byUser timed
deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
deleteCIFile user file_ = deleteCIFile user file_ =
forM_ file_ $ \file -> do forM_ file_ $ \file -> do
let filesInfo = [mkCIFileInfo file] fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True
cancelFilesInProgress user filesInfo deleteAgentConnectionsAsync user fileAgentConnIds
deleteFilesLocally filesInfo
markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse
markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do
@@ -6218,8 +6185,8 @@ markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ del
cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m ()
cancelCIFile user file_ = cancelCIFile user file_ =
forM_ file_ $ \file -> do forM_ file_ $ \file -> do
let filesInfo = [mkCIFileInfo file] fileAgentConnIds <- cancelFile' user (mkCIFileInfo file) True
cancelFilesInProgress user filesInfo deleteAgentConnectionsAsync user fileAgentConnIds
createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId) createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> m (CommandId, ConnId)
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
@@ -6261,43 +6228,20 @@ agentXFTPDeleteRcvFile aFileId fileId = do
withAgent (`xftpDeleteRcvFile` aFileId) withAgent (`xftpDeleteRcvFile` aFileId)
withStore' $ \db -> setRcvFTAgentDeleted db fileId withStore' $ \db -> setRcvFTAgentDeleted db fileId
agentXFTPDeleteRcvFiles :: ChatMonad m => [(XFTPRcvFile, FileTransferId)] -> m ()
agentXFTPDeleteRcvFiles rcvFiles = do
let rcvFiles' = filter (not . agentRcvFileDeleted . fst) rcvFiles
rfIds = mapMaybe fileIds rcvFiles'
withAgent $ \a -> xftpDeleteRcvFiles a (map fst rfIds)
void . withStoreBatch' $ \db -> map (setRcvFTAgentDeleted db . snd) rfIds
where
fileIds :: (XFTPRcvFile, FileTransferId) -> Maybe (RcvFileId, FileTransferId)
fileIds (XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId)}, fileId) = Just (aFileId, fileId)
fileIds _ = Nothing
agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m () agentXFTPDeleteSndFileRemote :: ChatMonad m => User -> XFTPSndFile -> FileTransferId -> m ()
agentXFTPDeleteSndFileRemote user xsf fileId = agentXFTPDeleteSndFileRemote user sndFile fileId = do
agentXFTPDeleteSndFilesRemote user [(xsf, fileId)] -- the agent doesn't know about redirect, delete explicitly
redirect_ <- withStore' $ \db -> lookupFileTransferRedirectMeta db user fileId
agentXFTPDeleteSndFilesRemote :: forall m. ChatMonad m => User -> [(XFTPSndFile, FileTransferId)] -> m () forM_ redirect_ $ \FileTransferMeta {fileId = fileIdRedirect, xftpSndFile = sndFileRedirect_} ->
agentXFTPDeleteSndFilesRemote user sndFiles = do mapM_ (handleError (const $ pure ()) . remove fileIdRedirect) sndFileRedirect_
(_errs, redirects) <- partitionEithers <$> withStoreBatch' (\db -> map (lookupFileTransferRedirectMeta db user . snd) sndFiles) remove fileId sndFile
let redirects' = mapMaybe mapRedirectMeta $ concat redirects
sndFilesAll = redirects' <> sndFiles
sndFilesAll' = filter (not . agentSndFileDeleted . fst) sndFilesAll
sndFilesAll'' <- catMaybes <$> mapM sndFileDescr sndFilesAll'
let sfs = map (\(XFTPSndFile {agentSndFileId = AgentSndFileId aFileId}, sfd, _) -> (aFileId, sfd)) sndFilesAll''
withAgent $ \a -> xftpDeleteSndFilesRemote a (aUserId user) sfs
void . withStoreBatch' $ \db -> map (setSndFTAgentDeleted db user . (\(_, _, fId) -> fId)) sndFilesAll''
where where
mapRedirectMeta :: FileTransferMeta -> Maybe (XFTPSndFile, FileTransferId) remove fId XFTPSndFile {agentSndFileId = AgentSndFileId aFileId, privateSndFileDescr, agentSndFileDeleted} =
mapRedirectMeta FileTransferMeta {fileId = fileId, xftpSndFile = Just sndFileRedirect} = Just (sndFileRedirect, fileId) unless agentSndFileDeleted $ do
mapRedirectMeta _ = Nothing forM_ privateSndFileDescr $ \sfdText -> do
sndFileDescr :: (XFTPSndFile, FileTransferId) -> m (Maybe (XFTPSndFile, ValidFileDescription 'FSender, FileTransferId)) sd <- parseFileDescription sfdText
sndFileDescr (xsf@XFTPSndFile {privateSndFileDescr}, fileId) = withAgent $ \a -> xftpDeleteSndFileRemote a (aUserId user) aFileId sd
join <$> forM privateSndFileDescr parseSndDescr withStore' $ \db -> setSndFTAgentDeleted db user fId
where
parseSndDescr sfdText =
tryChatError (parseFileDescription sfdText) >>= \case
Left _ -> pure Nothing
Right sd -> pure $ Just (xsf, sd, fileId)
userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile
userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do userProfileToSend user@User {profile = p} incognitoProfile ct inGroup = do

View File

@@ -1252,14 +1252,6 @@ mkChatError :: SomeException -> ChatError
mkChatError = ChatError . CEException . show mkChatError = ChatError . CEException . show
{-# INLINE mkChatError #-} {-# INLINE mkChatError #-}
catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a
catchStoreError = catchAllErrors mkStoreError
{-# INLINE catchStoreError #-}
mkStoreError :: SomeException -> StoreError
mkStoreError = SEInternalError . show
{-# INLINE mkStoreError #-}
chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError :: Maybe User -> String -> ChatResponse
chatCmdError user = CRChatCmdError user . ChatError . CECommandError chatCmdError user = CRChatCmdError user . ChatError . CECommandError

View File

@@ -1,30 +0,0 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20240226_users_restrict where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20240226_users_restrict :: Query
m20240226_users_restrict =
[sql|
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'ON DELETE CASCADE', 'ON DELETE RESTRICT')
WHERE name = 'users' AND type = 'table';
PRAGMA writable_schema=0;
|]
down_m20240226_users_restrict :: Query
down_m20240226_users_restrict =
[sql|
PRAGMA writable_schema=1;
UPDATE sqlite_master
SET sql = replace(sql, 'ON DELETE RESTRICT', 'ON DELETE CASCADE')
WHERE name = 'users' AND type = 'table';
PRAGMA writable_schema=0;
|]

View File

@@ -22,7 +22,7 @@ CREATE TABLE contact_profiles(
); );
CREATE TABLE users( CREATE TABLE users(
user_id INTEGER PRIMARY KEY, user_id INTEGER PRIMARY KEY,
contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE RESTRICT contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED, DEFERRABLE INITIALLY DEFERRED,
local_display_name TEXT NOT NULL UNIQUE, local_display_name TEXT NOT NULL UNIQUE,
active_user INTEGER NOT NULL DEFAULT 0, active_user INTEGER NOT NULL DEFAULT 0,
@@ -37,7 +37,7 @@ CREATE TABLE users(
user_member_profile_updated_at TEXT, -- 1 for active user user_member_profile_updated_at TEXT, -- 1 for active user
FOREIGN KEY(user_id, local_display_name) FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name)
ON DELETE RESTRICT ON DELETE CASCADE
ON UPDATE CASCADE ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED DEFERRABLE INITIALLY DEFERRED
); );

View File

@@ -100,7 +100,6 @@ import Simplex.Chat.Migrations.M20240115_block_member_for_all
import Simplex.Chat.Migrations.M20240122_indexes import Simplex.Chat.Migrations.M20240122_indexes
import Simplex.Chat.Migrations.M20240214_redirect_file_id import Simplex.Chat.Migrations.M20240214_redirect_file_id
import Simplex.Chat.Migrations.M20240222_app_settings import Simplex.Chat.Migrations.M20240222_app_settings
import Simplex.Chat.Migrations.M20240226_users_restrict
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@@ -200,8 +199,7 @@ schemaMigrations =
("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all), ("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all),
("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes),
("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id),
("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings), ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings)
("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date

View File

@@ -46,7 +46,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Util import Simplex.Chat.Types.Util
import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Description (FileDigest)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
@@ -1142,7 +1142,7 @@ instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f
instance ToField AgentConnId where toField (AgentConnId m) = toField m instance ToField AgentConnId where toField (AgentConnId m) = toField m
newtype AgentSndFileId = AgentSndFileId SndFileId newtype AgentSndFileId = AgentSndFileId ConnId
deriving (Eq, Show) deriving (Eq, Show)
instance StrEncoding AgentSndFileId where instance StrEncoding AgentSndFileId where
@@ -1161,7 +1161,7 @@ instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromFie
instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m
newtype AgentRcvFileId = AgentRcvFileId RcvFileId newtype AgentRcvFileId = AgentRcvFileId ConnId
deriving (Eq, Show) deriving (Eq, Show)
instance StrEncoding AgentRcvFileId where instance StrEncoding AgentRcvFileId where

View File

@@ -20,6 +20,7 @@ import Simplex.Chat.Options (ChatOpts (..))
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..)) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util (unlessM)
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize) import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize)
import Test.Hspec hiding (it) import Test.Hspec hiding (it)