Compare commits
18 Commits
master
...
av/ios-mig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc605ffb9 | ||
|
|
fa2bdaf477 | ||
|
|
ac7a3e5b96 | ||
|
|
f06cef49a3 | ||
|
|
be7730f1be | ||
|
|
90f333cfc6 | ||
|
|
ade02fd873 | ||
|
|
0e6dd6f4a0 | ||
|
|
cb694ad89b | ||
|
|
bbeeaac6ca | ||
|
|
77d06e6764 | ||
|
|
a7eaf4ec0f | ||
|
|
0c65d88e13 | ||
|
|
3e64ef96b1 | ||
|
|
350a6bff00 | ||
|
|
665cdd9b0e | ||
|
|
0f1f9893cc | ||
|
|
701f5d7cdc |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
515
apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift
Normal file
515
apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
670
apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift
Normal file
670
apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct HowItWorks: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if onboarding {
|
if onboarding {
|
||||||
OnboardingActionButton()
|
OnboardingActionButton(hideMigrate: true)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|]
|
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user