mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-24 19:35:33 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -95,6 +95,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
@Published var showingInvitation: ShowingInvitation?
|
||||
@Published var migrationState: MigrationFromAnotherDeviceState? = MigrationFromAnotherDeviceState.transform()
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
|
||||
@@ -90,12 +90,12 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
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)")
|
||||
let start = Date.now
|
||||
let resp = bgTask
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) }
|
||||
: sendSimpleXCmd(cmd)
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||
: sendSimpleXCmd(cmd, ctrl)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
@@ -106,24 +106,24 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
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
|
||||
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
|
||||
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
|
||||
let resp = recvSimpleXMsg()
|
||||
let resp = recvSimpleXMsg(ctrl)
|
||||
cont.resume(returning: resp)
|
||||
return resp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetActiveUser() throws -> User? {
|
||||
let r = chatSendCmdSync(.showActiveUser)
|
||||
func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
|
||||
let r = chatSendCmdSync(.showActiveUser, ctrl)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
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 {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp))
|
||||
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), ctrl)
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -210,8 +210,8 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiStartChat() throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true))
|
||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -240,14 +240,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
logger.error("apiSuspendChat error: \(String(describing: r))")
|
||||
}
|
||||
|
||||
func apiSetTempFolder(tempFolder: String) throws {
|
||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder))
|
||||
func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetFilesFolder(filesFolder: String) throws {
|
||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
|
||||
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
@@ -258,6 +258,18 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSaveAppSettings(settings: AppSettings) throws {
|
||||
let r = chatSendCmdSync(.apiSaveSettings(settings: settings))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
|
||||
let r = chatSendCmdSync(.apiGetSettings(settings: settings))
|
||||
if case let .appSettings(settings) = r { return settings }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetPQEncryption(_ enable: Bool) throws {
|
||||
let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
@@ -288,6 +300,10 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
|
||||
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] {
|
||||
let userId = try currentUserId("apiGetChats")
|
||||
return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId)))
|
||||
@@ -510,8 +526,8 @@ func getNetworkConfig() async throws -> NetCfg? {
|
||||
throw r
|
||||
}
|
||||
|
||||
func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg))
|
||||
func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
|
||||
let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
@@ -876,6 +892,36 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
|
||||
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 standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? {
|
||||
let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl)
|
||||
if case let .standaloneFileInfo(fileMeta) = r {
|
||||
return fileMeta
|
||||
} else {
|
||||
logger.error("standaloneFileInfo error: \(String(describing: r))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
|
||||
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
@@ -921,8 +967,8 @@ func cancelFile(user: User, fileId: Int64) async {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.cancelFile(fileId: fileId))
|
||||
func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl)
|
||||
switch r {
|
||||
case let .sndFileCancelled(_, chatItem, _, _) : return chatItem
|
||||
case let .rcvFileCancelled(_, chatItem, _) : return chatItem
|
||||
@@ -1094,8 +1140,8 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
let r = await chatSendCmd(cmd)
|
||||
private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws {
|
||||
let r = await chatSendCmd(cmd, ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
@@ -1336,6 +1382,16 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
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?) {
|
||||
do {
|
||||
try changeActiveUser_(userId, viewPwd: viewPwd)
|
||||
@@ -1714,27 +1770,37 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .rcvFileSndCancelled(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileError(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
}
|
||||
case let .rcvFileError(user, aChatItem, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
}
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupDirectFile(aChatItem) }
|
||||
case let .sndFileRcvCancelled(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupDirectFile(aChatItem) }
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupDirectFile(aChatItem) }
|
||||
}
|
||||
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
}
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .sndFileError(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .sndFileError(user, aChatItem, _):
|
||||
if let aChatItem = aChatItem {
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
}
|
||||
case let .callInvitation(invitation):
|
||||
await MainActor.run {
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
|
||||
@@ -44,7 +44,12 @@ struct SimpleXApp: App {
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
.onAppear() {
|
||||
if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
|
||||
// Present screen for continue migration if it wasn't finished yet
|
||||
if chatModel.migrationState != nil {
|
||||
// It's important, otherwise, user may be locked in undefined state
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
chatModel.onboardingStage = onboardingStageDefault.get()
|
||||
} else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
|
||||
@@ -110,12 +110,11 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .sndModerated: deletedItemView()
|
||||
case .rcvModerated: deletedItemView()
|
||||
case .rcvBlocked: deletedItemView()
|
||||
case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
|
||||
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
|
||||
case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
|
||||
case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
|
||||
case let .invalidJSON(json): CIInvalidJSONView(json: json)
|
||||
// TODO proper items
|
||||
case .sndDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text))
|
||||
case .rcvDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text))
|
||||
case .sndGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text))
|
||||
case .rcvGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +174,22 @@ struct ChatItemContentView<Content: View>: View {
|
||||
Text(members)
|
||||
}
|
||||
}
|
||||
|
||||
private func directE2EEInfoText(_ info: E2EEInfo) -> Text {
|
||||
info.pqEnabled
|
||||
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
: e2eeInfoNoPQText()
|
||||
}
|
||||
|
||||
private func e2eeInfoNoPQText() -> Text {
|
||||
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ text: Text) -> Text {
|
||||
|
||||
@@ -35,7 +35,7 @@ struct ContactPreferencesView: View {
|
||||
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton {
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if currentFeaturesAllowed == featuresAllowed {
|
||||
dismiss()
|
||||
} else {
|
||||
|
||||
@@ -48,7 +48,7 @@ struct GroupPreferencesView: View {
|
||||
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
|
||||
}
|
||||
}
|
||||
.modifier(BackButton {
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if currentPreferences == preferences {
|
||||
dismiss()
|
||||
} else {
|
||||
|
||||
@@ -24,7 +24,7 @@ struct GroupWelcomeView: View {
|
||||
VStack {
|
||||
if groupInfo.canEdit {
|
||||
editorView()
|
||||
.modifier(BackButton {
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if welcomeTextUnchanged() {
|
||||
dismiss()
|
||||
} else {
|
||||
|
||||
@@ -36,6 +36,7 @@ enum DatabaseEncryptionAlert: Identifiable {
|
||||
struct DatabaseEncryptionView: View {
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@Binding var useKeychain: Bool
|
||||
var migration: Bool
|
||||
@State private var alert: DatabaseEncryptionAlert? = nil
|
||||
@State private var progressIndicator = false
|
||||
@State private var useKeychainToggle = storeDBPassphraseGroupDefault.get()
|
||||
@@ -48,7 +49,12 @@ struct DatabaseEncryptionView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
databaseEncryptionView()
|
||||
List {
|
||||
if migration {
|
||||
chatStoppedView()
|
||||
}
|
||||
databaseEncryptionView()
|
||||
}
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
@@ -56,72 +62,71 @@ struct DatabaseEncryptionView: View {
|
||||
}
|
||||
|
||||
private func databaseEncryptionView() -> some View {
|
||||
List {
|
||||
Section {
|
||||
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
|
||||
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
|
||||
Section {
|
||||
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
|
||||
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
|
||||
.onChange(of: useKeychainToggle) { _ in
|
||||
if useKeychainToggle {
|
||||
setUseKeychain(true)
|
||||
} else if storedKey {
|
||||
} else if storedKey && !migration {
|
||||
// Don't show in migration process since it will remove the key after successfull encryption
|
||||
alert = .keychainRemoveKey
|
||||
} else {
|
||||
setUseKeychain(false)
|
||||
}
|
||||
}
|
||||
.disabled(initialRandomDBPassphrase)
|
||||
}
|
||||
.disabled(initialRandomDBPassphrase && !migration)
|
||||
}
|
||||
|
||||
if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
|
||||
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
}
|
||||
if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
|
||||
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)
|
||||
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("Update database passphrase") {
|
||||
alert = currentKey == ""
|
||||
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
|
||||
: (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey)
|
||||
}
|
||||
settingsRow("lock.rotation") {
|
||||
Button(migration ? "Set passphrase" : "Update database passphrase") {
|
||||
alert = currentKey == ""
|
||||
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
|
||||
: (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey)
|
||||
}
|
||||
.disabled(
|
||||
(m.chatDbEncrypted == true && currentKey == "") ||
|
||||
currentKey == newKey ||
|
||||
newKey != confirmNewKey ||
|
||||
newKey == "" ||
|
||||
!validKey(currentKey) ||
|
||||
!validKey(newKey)
|
||||
)
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if m.chatDbEncrypted == false {
|
||||
Text("Your chat database is not encrypted - set passphrase to encrypt it.")
|
||||
} else if useKeychain {
|
||||
if storedKey {
|
||||
Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.")
|
||||
if initialRandomDBPassphrase {
|
||||
Text("Database is encrypted using a random passphrase, you can change it.")
|
||||
} else {
|
||||
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
(m.chatDbEncrypted == true && currentKey == "") ||
|
||||
currentKey == newKey ||
|
||||
newKey != confirmNewKey ||
|
||||
newKey == "" ||
|
||||
!validKey(currentKey) ||
|
||||
!validKey(newKey)
|
||||
)
|
||||
} header: {
|
||||
Text(migration ? "Database passphrase" : "")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if m.chatDbEncrypted == false {
|
||||
Text("Your chat database is not encrypted - set passphrase to encrypt it.")
|
||||
} else if useKeychain {
|
||||
if storedKey {
|
||||
Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.")
|
||||
if initialRandomDBPassphrase && !migration {
|
||||
Text("Database is encrypted using a random passphrase, you can change it.")
|
||||
} else {
|
||||
Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.")
|
||||
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
||||
}
|
||||
} else {
|
||||
Text("You have to enter passphrase every time the app starts - it is not stored on the device.")
|
||||
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
||||
if m.notificationMode == .instant && m.notificationPreview != .hidden {
|
||||
Text("**Warning**: Instant push notifications require passphrase saved in Keychain.")
|
||||
}
|
||||
Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.")
|
||||
}
|
||||
} else {
|
||||
Text("You have to enter passphrase every time the app starts - it is not stored on the device.")
|
||||
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
||||
if m.notificationMode == .instant && m.notificationPreview != .hidden && !migration {
|
||||
Text("**Warning**: Instant push notifications require passphrase saved in Keychain.")
|
||||
}
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.font(.callout)
|
||||
}
|
||||
.onAppear {
|
||||
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
|
||||
@@ -136,9 +141,15 @@ struct DatabaseEncryptionView: View {
|
||||
do {
|
||||
encryptionStartedDefault.set(true)
|
||||
encryptionStartedAtDefault.set(Date.now)
|
||||
if !m.chatDbChanged {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
|
||||
encryptionStartedDefault.set(false)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
if migration {
|
||||
storeDBPassphraseGroupDefault.set(useKeychain)
|
||||
}
|
||||
if useKeychain {
|
||||
if kcDatabasePassword.set(newKey) {
|
||||
await resetFormAfterEncryption(true)
|
||||
@@ -148,6 +159,9 @@ struct DatabaseEncryptionView: View {
|
||||
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
|
||||
}
|
||||
} else {
|
||||
if migration {
|
||||
removePassphraseFromKeyChain()
|
||||
}
|
||||
await resetFormAfterEncryption()
|
||||
await operationEnded(.databaseEncrypted)
|
||||
}
|
||||
@@ -174,7 +188,10 @@ struct DatabaseEncryptionView: View {
|
||||
|
||||
private func setUseKeychain(_ value: Bool) {
|
||||
useKeychain = value
|
||||
storeDBPassphraseGroupDefault.set(value)
|
||||
// Postpone it when migrating to the end of encryption process
|
||||
if !migration {
|
||||
storeDBPassphraseGroupDefault.set(value)
|
||||
}
|
||||
}
|
||||
|
||||
private func databaseEncryptionAlert(_ alertItem: DatabaseEncryptionAlert) -> Alert {
|
||||
@@ -184,13 +201,7 @@ struct DatabaseEncryptionView: View {
|
||||
title: Text("Remove passphrase from keychain?"),
|
||||
message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
if kcDatabasePassword.remove() {
|
||||
logger.debug("passphrase removed from keychain")
|
||||
setUseKeychain(false)
|
||||
storedKey = false
|
||||
} else {
|
||||
alert = .error(title: "Keychain error", error: "Failed to remove passphrase")
|
||||
}
|
||||
removePassphraseFromKeyChain()
|
||||
},
|
||||
secondaryButton: .cancel() {
|
||||
withAnimation { useKeychainToggle = true }
|
||||
@@ -236,6 +247,16 @@ struct DatabaseEncryptionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removePassphraseFromKeyChain() {
|
||||
if kcDatabasePassword.remove() {
|
||||
logger.debug("passphrase removed from keychain")
|
||||
setUseKeychain(false)
|
||||
storedKey = false
|
||||
} else {
|
||||
alert = .error(title: "Keychain error", error: "Failed to remove passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
private func storeSecurelySaved() -> Text {
|
||||
Text("Please store passphrase securely, you will NOT be able to change it if you lose it.")
|
||||
}
|
||||
@@ -346,6 +367,6 @@ func validKey(_ s: String) -> Bool {
|
||||
|
||||
struct DatabaseEncryptionView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true))
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
|
||||
case let .migrationError(mtrError):
|
||||
titleText("Incompatible database version")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
@@ -105,7 +105,7 @@ struct DatabaseErrorView: View {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
private func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
|
||||
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
|
||||
switch err {
|
||||
case let .noDown(dbMigrations):
|
||||
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
|
||||
|
||||
@@ -116,7 +116,7 @@ struct DatabaseView: View {
|
||||
let color: Color = unencrypted ? .orange : .secondary
|
||||
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
|
||||
NavigationLink {
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain)
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
|
||||
.navigationTitle("Database passphrase")
|
||||
} label: {
|
||||
Text("Database passphrase")
|
||||
@@ -485,6 +485,10 @@ func deleteChatAsync() async throws {
|
||||
_ = kcDatabasePassword.remove()
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
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 {
|
||||
|
||||
@@ -188,6 +188,7 @@ struct MigrateToAppGroupView: View {
|
||||
let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path)
|
||||
Task {
|
||||
do {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
try await apiExportArchive(config: config)
|
||||
await MainActor.run { setV3DBMigration(.exported) }
|
||||
} catch let error {
|
||||
@@ -204,7 +205,11 @@ struct MigrateToAppGroupView: View {
|
||||
resetChatCtrl()
|
||||
try await MainActor.run { try initializeChat(start: false) }
|
||||
let _ = try await apiImportArchive(config: config)
|
||||
await MainActor.run { setV3DBMigration(.migrated) }
|
||||
let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
await MainActor.run {
|
||||
appSettings.importIntoApp()
|
||||
setV3DBMigration(.migrated)
|
||||
}
|
||||
} catch let error {
|
||||
dbContainerGroupDefault.set(.documents)
|
||||
await MainActor.run {
|
||||
@@ -216,16 +221,22 @@ struct MigrateToAppGroupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func exportChatArchive() async throws -> URL {
|
||||
func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
|
||||
let archiveTime = Date.now
|
||||
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
|
||||
let archiveName = "simplex-chat.\(ts).zip"
|
||||
let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName)
|
||||
let archivePath = (storagePath ?? getDocumentsDirectory()).appendingPathComponent(archiveName)
|
||||
let config = ArchiveConfig(archivePath: archivePath.path)
|
||||
// Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first
|
||||
if !ChatModel.shared.chatDbChanged {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try await apiExportArchive(config: config)
|
||||
deleteOldArchive()
|
||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||
chatArchiveTimeDefault.set(archiveTime)
|
||||
if storagePath == nil {
|
||||
deleteOldArchive()
|
||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||
chatArchiveTimeDefault.set(archiveTime)
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
//
|
||||
// MigrateFromAnotherDevice.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 23.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum MigrationFromAnotherDeviceState: Codable, Equatable {
|
||||
case downloadProgress(link: String, archiveName: String)
|
||||
case archiveImport(archiveName: String)
|
||||
case passphrase
|
||||
|
||||
func makeMigrationState() -> MigrationFromState {
|
||||
var initial: MigrationFromState = .pasteOrScanLink
|
||||
//logger.debug("Inited with migrationState: \(String(describing: self))")
|
||||
switch self {
|
||||
case let .downloadProgress(link, archiveName):
|
||||
// iOS changes absolute directory every launch, check this way
|
||||
let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName
|
||||
initial = .downloadFailed(totalBytes: 0, link: link, archivePath: archivePath)
|
||||
case let .archiveImport(archiveName):
|
||||
let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName
|
||||
initial = .archiveImportFailed(archivePath: archivePath)
|
||||
case .passphrase:
|
||||
initial = .passphrase(passphrase: "")
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
// Here we check whether it's needed to show migration process after app restart or not
|
||||
// It's important to NOT show the process when archive was corrupted/not fully downloaded
|
||||
static func transform() -> MigrationFromAnotherDeviceState? {
|
||||
let state: MigrationFromAnotherDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE)!) : nil
|
||||
|
||||
if case let .downloadProgress(_, archiveName) = state {
|
||||
// iOS changes absolute directory every launch, check this way
|
||||
let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName
|
||||
try? FileManager.default.removeItem(atPath: archivePath)
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE)
|
||||
// No migration happens at the moment actually since archive were not downloaded fully
|
||||
logger.debug("MigrateFromDevice: archive wasn't fully downloaded, removed broken file")
|
||||
return nil
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
static func save(_ state: MigrationFromAnotherDeviceState?, apply: (MigrationFromAnotherDeviceState?) -> Void) {
|
||||
if let state {
|
||||
UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_STAGE)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE)
|
||||
}
|
||||
apply(state)
|
||||
}
|
||||
}
|
||||
|
||||
enum MigrationFromState: Equatable {
|
||||
case pasteOrScanLink
|
||||
case linkDownloading(link: String)
|
||||
case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: String, ctrl: chat_ctrl?)
|
||||
case downloadFailed(totalBytes: Int64, link: String, archivePath: String)
|
||||
case archiveImport(archivePath: String)
|
||||
case archiveImportFailed(archivePath: String)
|
||||
case passphrase(passphrase: String)
|
||||
case migrationConfirmation(status: DBMigrationResult, passphrase: String, useKeychain: Bool)
|
||||
case migration(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Bool)
|
||||
case onion(appSettings: AppSettings)
|
||||
}
|
||||
|
||||
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
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State var migrationState: MigrationFromState
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var alert: MigrateFromAnotherDeviceViewAlert?
|
||||
private let tempDatabaseUrl = urlForTemporaryDatabase()
|
||||
@State private var chatReceiver: MigrationChatReceiver? = nil
|
||||
// Prevent from hiding the view until migration is finished or app deleted
|
||||
@State private var backDisabled: Bool = false
|
||||
@State private var showQRCodeScanner: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch migrationState {
|
||||
case .pasteOrScanLink:
|
||||
pasteOrScanLinkView()
|
||||
case let .linkDownloading(link):
|
||||
linkDownloadingView(link)
|
||||
case let .downloadProgress(downloaded, total, _, _, _, _):
|
||||
downloadProgressView(downloaded, totalBytes: total)
|
||||
case let .downloadFailed(total, link, archivePath):
|
||||
downloadFailedView(totalBytes: total, link, archivePath)
|
||||
case let .archiveImport(archivePath):
|
||||
archiveImportView(archivePath)
|
||||
case let .archiveImportFailed(archivePath):
|
||||
archiveImportFailedView(archivePath)
|
||||
case let .passphrase(passphrase):
|
||||
PassphraseEnteringView(migrationState: $migrationState, currentKey: passphrase, alert: $alert)
|
||||
case let .migrationConfirmation(status, passphrase, useKeychain):
|
||||
migrationConfirmationView(status, passphrase, useKeychain)
|
||||
case let .migration(passphrase, confirmation, useKeychain):
|
||||
migrationView(passphrase, confirmation, useKeychain)
|
||||
case let .onion(appSettings):
|
||||
OnionView(appSettings: appSettings, finishMigration: finishMigration)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
backDisabled = switch migrationState {
|
||||
case .linkDownloading: false
|
||||
case .downloadProgress: false
|
||||
case .archiveImportFailed: false
|
||||
default: m.migrationState != nil
|
||||
}
|
||||
}
|
||||
.onChange(of: migrationState) { state in
|
||||
backDisabled = switch state {
|
||||
case .linkDownloading: false
|
||||
case .downloadProgress: false
|
||||
case .archiveImportFailed: false
|
||||
default: m.migrationState != nil
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
Task {
|
||||
if case .archiveImportFailed = migrationState {
|
||||
// Original database is not exist, nothing is setup correctly for showing to a user yet. Return to clean state
|
||||
deleteAppDatabaseAndFiles()
|
||||
initChatAndMigrate()
|
||||
} else if case let .downloadProgress(_, _, fileId, _, _, ctrl) = migrationState, let ctrl {
|
||||
await stopArchiveDownloading(fileId, ctrl)
|
||||
}
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
if !backDisabled {
|
||||
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
||||
MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
.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() -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section("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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if developerTools {
|
||||
Section("Or paste archive link") {
|
||||
pasteLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Downloading archive")
|
||||
}
|
||||
}
|
||||
let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1))
|
||||
MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadFailedView(totalBytes: Int64, _ link: String, _ archivePath: String) -> some View {
|
||||
List {
|
||||
Section {
|
||||
Button(action: {
|
||||
try? FileManager.default.removeItem(atPath: archivePath)
|
||||
migrationState = .linkDownloading(link: link)
|
||||
}) {
|
||||
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?.stopAndCleanUp()
|
||||
try? FileManager.default.removeItem(atPath: archivePath)
|
||||
MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func archiveImportView(_ archivePath: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Importing archive")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
.onAppear {
|
||||
importArchive(archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func archiveImportFailedView(_ archivePath: String) -> some View {
|
||||
List {
|
||||
Section {
|
||||
Button(action: {
|
||||
migrationState = .archiveImport(archivePath: archivePath)
|
||||
}) {
|
||||
settingsRow("square.and.arrow.down") {
|
||||
Text("Repeat import").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import failed")
|
||||
} footer: {
|
||||
Text("You can give another try.")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func migrationConfirmationView(_ status: DBMigrationResult, _ passphrase: String, _ useKeychain: Bool) -> some View {
|
||||
List {
|
||||
let (header, button, footer, confirmation): (LocalizedStringKey, LocalizedStringKey?, String, MigrationConfirmation?) = switch status {
|
||||
case let .errorMigration(_, migrationError):
|
||||
switch migrationError {
|
||||
case .upgrade:
|
||||
("Database upgrade",
|
||||
"Upgrade and open chat",
|
||||
"",
|
||||
.yesUp)
|
||||
case .downgrade:
|
||||
("Database downgrade",
|
||||
"Downgrade and open chat",
|
||||
NSLocalizedString("Warning: you may lose some data!", comment: ""),
|
||||
.yesUpDown)
|
||||
case let .migrationError(mtrError):
|
||||
("Incompatible database version",
|
||||
nil,
|
||||
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
|
||||
nil)
|
||||
}
|
||||
default: ("Error", nil, "Unknown error", nil)
|
||||
}
|
||||
Section {
|
||||
if let button, let confirmation {
|
||||
Button(action: {
|
||||
migrationState = .migration(passphrase: passphrase, confirmation: confirmation, useKeychain: useKeychain)
|
||||
}) {
|
||||
settingsRow("square.and.arrow.down") {
|
||||
Text(button).foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
} header: {
|
||||
Text(header)
|
||||
} footer: {
|
||||
Text(footer)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func migrationView(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Migrating")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
.onAppear {
|
||||
startChat(passphrase, confirmation, useKeychain)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnionView: View {
|
||||
@State var appSettings: AppSettings
|
||||
@State private var onionHosts: OnionHosts = .no
|
||||
var finishMigration: (AppSettings) -> Void
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Button(action: {
|
||||
var updated = appSettings.networkConfig!
|
||||
let (hostMode, requiredHostMode) = onionHosts.hostMode
|
||||
updated.hostMode = hostMode
|
||||
updated.requiredHostMode = requiredHostMode
|
||||
updated.socksProxy = nil
|
||||
appSettings.networkConfig = updated
|
||||
finishMigration(appSettings)
|
||||
}) {
|
||||
settingsRow("checkmark") {
|
||||
Text("Apply").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Confirm network settings")
|
||||
} footer: {
|
||||
Text("Please confirm that network settings are correct for this device.")
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
Section("Network settings") {
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.path)
|
||||
}
|
||||
|
||||
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: String) {
|
||||
Task {
|
||||
guard let ctrlAndUser = initTemporaryDatabase() else {
|
||||
return migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
||||
}
|
||||
let (ctrl, user) = ctrlAndUser
|
||||
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
|
||||
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)
|
||||
MigrationFromAnotherDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 }
|
||||
case .rcvStandaloneFileComplete:
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .archiveImport(archivePath: archivePath)
|
||||
MigrationFromAnotherDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 }
|
||||
}
|
||||
case .rcvFileError:
|
||||
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
|
||||
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
||||
default:
|
||||
logger.debug("unsupported event: \(msg.responseType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
chatReceiver?.start()
|
||||
|
||||
let (res, error) = await downloadStandaloneFile(user: user, url: link, file: CryptoFile.plain(URL(fileURLWithPath: archivePath).lastPathComponent), ctrl: ctrl)
|
||||
if res == nil {
|
||||
await MainActor.run {
|
||||
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 {
|
||||
if !hasChatCtrl() {
|
||||
chatInitControllerRemovingDatabases()
|
||||
}
|
||||
try await apiDeleteStorage()
|
||||
do {
|
||||
let config = ArchiveConfig(archivePath: archivePath)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
if !archiveErrors.isEmpty {
|
||||
alert = .chatImportedWithErrors()
|
||||
}
|
||||
await MainActor.run {
|
||||
migrationState = .passphrase(passphrase: "")
|
||||
MigrationFromAnotherDeviceState.save(.passphrase) { m.migrationState = $0 }
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
migrationState = .archiveImportFailed(archivePath: archivePath)
|
||||
}
|
||||
alert = .error(title: "Error importing chat database", error: responseError(error))
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
migrationState = .archiveImportFailed(archivePath: archivePath)
|
||||
}
|
||||
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 startChat(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) {
|
||||
if useKeychain {
|
||||
_ = kcDatabasePassword.set(passphrase)
|
||||
} else {
|
||||
_ = kcDatabasePassword.remove()
|
||||
}
|
||||
storeDBPassphraseGroupDefault.set(useKeychain)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
AppChatState.shared.set(.active)
|
||||
Task {
|
||||
do {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: false, confirmStart: false, dbKey: passphrase, refreshInvitations: true, confirmMigrations: confirmation)
|
||||
var appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
await MainActor.run {
|
||||
if appSettings.networkConfig?.hostMode == .onionViaSocks || appSettings.networkConfig?.hostMode == .onionHost || appSettings.networkConfig?.socksProxy != nil {
|
||||
appSettings.networkConfig?.socksProxy = nil
|
||||
appSettings.networkConfig?.hostMode = .publicHost
|
||||
appSettings.networkConfig?.requiredHostMode = true
|
||||
migrationState = .onion(appSettings: appSettings)
|
||||
} else {
|
||||
finishMigration(appSettings)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
hideView()
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishMigration(_ appSettings: AppSettings) {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
||||
MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 }
|
||||
appSettings.importIntoApp()
|
||||
try SimpleX.startChat(refreshInvitations: true)
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
hideView()
|
||||
}
|
||||
|
||||
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: MigrationFromState
|
||||
@State private var useKeychain = true
|
||||
@State var currentKey: String
|
||||
@State private var verifyingPassphrase: Bool = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@Binding var alert: MigrateFromAnotherDeviceViewAlert?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {
|
||||
settingsRow("key", color: .secondary) {
|
||||
Toggle("Save passphrase in Keychain", isOn: $useKeychain)
|
||||
}
|
||||
|
||||
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
.focused($keyboardVisible)
|
||||
Button(action: {
|
||||
verifyingPassphrase = true
|
||||
hideKeyboard()
|
||||
Task {
|
||||
let (status, _) = chatInitTemporaryDatabase(url: getAppDatabasePath(), key: currentKey, confirmation: .yesUp)
|
||||
let success = switch status {
|
||||
case .ok, .invalidConfirmation: true
|
||||
default: false
|
||||
}
|
||||
if success {
|
||||
await MainActor.run {
|
||||
migrationState = .migration(passphrase: currentKey, confirmation: .yesUp, useKeychain: useKeychain)
|
||||
}
|
||||
} else if case .errorMigration = status {
|
||||
await MainActor.run {
|
||||
migrationState = .migrationConfirmation(status: status, passphrase: currentKey, useKeychain: useKeychain)
|
||||
}
|
||||
} else {
|
||||
showErrorOnMigrationIfNeeded(status, $alert)
|
||||
}
|
||||
verifyingPassphrase = false
|
||||
}
|
||||
}) {
|
||||
settingsRow("key", color: .secondary) {
|
||||
Text("Open chat")
|
||||
}
|
||||
}
|
||||
.disabled(verifyingPassphrase || currentKey.isEmpty)
|
||||
} header: {
|
||||
Text("Enter passphrase")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if useKeychain {
|
||||
Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.")
|
||||
} else {
|
||||
Text("You have to enter passphrase every time the app starts - it is not stored on the device.")
|
||||
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
||||
Text("**Warning**: Instant push notifications require passphrase saved in Keychain.")
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.top, 1)
|
||||
.onTapGesture { keyboardVisible = false }
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
keyboardVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
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 databaseUrl: URL
|
||||
let processReceivedMsg: (ChatResponse) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
|
||||
self.ctrl = ctrl
|
||||
self.databaseUrl = databaseUrl
|
||||
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) {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
}
|
||||
logger.debug("processReceivedMsg: \(msg.responseType)")
|
||||
await processReceivedMsg(msg)
|
||||
}
|
||||
if self.receiveMessages {
|
||||
_ = try? await Task.sleep(nanoseconds: 7_500_000)
|
||||
await receiveMsgLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func stopAndCleanUp() {
|
||||
logger.debug("MigrationChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
receiveLoop?.cancel()
|
||||
receiveLoop = nil
|
||||
chat_close_store(ctrl)
|
||||
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_chat.db")
|
||||
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_agent.db")
|
||||
}
|
||||
}
|
||||
|
||||
struct MigrateFromAnotherDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MigrateFromAnotherDevice(migrationState: .pasteOrScanLink)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
//
|
||||
// MigrateToAnotherDevice.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 14.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private enum MigrationToState: Equatable {
|
||||
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
|
||||
case linkShown(fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl)
|
||||
case finished(chatDeletion: Bool)
|
||||
}
|
||||
|
||||
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
|
||||
@Binding var showProgressOnSettings: Bool
|
||||
@State private var migrationState: MigrationToState = .chatStopInProgress
|
||||
@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)
|
||||
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 .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 .linkCreation:
|
||||
linkCreationView()
|
||||
case let .linkShown(fileId, link, archivePath, ctrl):
|
||||
linkShownView(fileId, link, archivePath, ctrl)
|
||||
case let .finished(chatDeletion):
|
||||
finishedView(chatDeletion)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Back", disabled: $backDisabled) {
|
||||
dismiss()
|
||||
})
|
||||
.onChange(of: migrationState) { state in
|
||||
backDisabled = switch migrationState {
|
||||
case .archiving: true
|
||||
case .linkCreation: true
|
||||
case .linkShown: true
|
||||
case .finished: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
stopChat()
|
||||
}
|
||||
.onDisappear {
|
||||
Task {
|
||||
if case .linkCreation = migrationState {} else if case .linkShown = migrationState {} else if case .finished = migrationState {} else {
|
||||
await MainActor.run {
|
||||
showProgressOnSettings = true
|
||||
}
|
||||
await startChatAndDismiss(false)
|
||||
await MainActor.run {
|
||||
showProgressOnSettings = false
|
||||
}
|
||||
}
|
||||
if case let .uploadProgress(_, _, fileId, _, ctrl) = migrationState, let ctrl {
|
||||
await cancelUploadedArchive(fileId, ctrl)
|
||||
}
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
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")) {
|
||||
Task {
|
||||
await 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 {
|
||||
List {
|
||||
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 securely encrypted and uploaded in chunks 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)
|
||||
MigrateToAnotherDevice.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?.stopAndCleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
private func linkCreationView() -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Creating archive link")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
}
|
||||
|
||||
private func linkShownView(_ 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("Choose _Migrate from another device_ on the new device and scan QR code.")
|
||||
.font(.callout)
|
||||
}
|
||||
Section("Show QR code") {
|
||||
SimpleXLinkQRCode(uri: link)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
|
||||
Section("Or securely share this file link") {
|
||||
shareLinkView(link)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private func finishedView(_ chatDeletion: Bool) -> some View {
|
||||
ZStack {
|
||||
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: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("You **must not** use the same database on two devices.")
|
||||
Text("**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection.")
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
if chatDeletion {
|
||||
progressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareLinkView(_ link: String) -> some View {
|
||||
HStack {
|
||||
linkTextView(link)
|
||||
Button {
|
||||
showShareSheet(items: [link])
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.padding(.top, -7)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func linkTextView(_ link: String) -> some View {
|
||||
Text(link)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
static func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
Text(description)
|
||||
.font(.title3)
|
||||
.hidden()
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 54))
|
||||
.bold()
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text(description)
|
||||
.font(.title3)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(value))
|
||||
.stroke(
|
||||
Color.accentColor,
|
||||
style: StrokeStyle(lineWidth: 27)
|
||||
)
|
||||
.rotationEffect(.degrees(180))
|
||||
.animation(.linear, value: value)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func stopChat() {
|
||||
Task {
|
||||
do {
|
||||
try await stopChatAsync()
|
||||
do {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
await MainActor.run {
|
||||
migrationState = initialRandomDBPassphraseGroupDefault.get() ? .passphraseNotSet : .passphraseConfirmation
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error saving settings", error: error.localizedDescription)
|
||||
migrationState = .chatStopFailed(reason: NSLocalizedString("Error saving settings", comment: "when migrating"))
|
||||
}
|
||||
} 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, databaseUrl: tempDatabaseUrl) { msg in
|
||||
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 .sndFileRedirectStartXFTP:
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .linkCreation
|
||||
}
|
||||
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
|
||||
let cfg = getNetCfg()
|
||||
let data = MigrationFileLinkData.init(
|
||||
networkConfig: MigrationFileLinkData.NetworkConfig(
|
||||
socksProxy: cfg.socksProxy,
|
||||
hostMode: cfg.hostMode,
|
||||
requiredHostMode: cfg.requiredHostMode
|
||||
)
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(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)
|
||||
await MainActor.run {
|
||||
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 cancelUploadedArchive(_ fileId: Int64, _ ctrl: chat_ctrl) async {
|
||||
_ = await apiCancelFile(fileId: fileId, ctrl: ctrl)
|
||||
}
|
||||
|
||||
private func cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
|
||||
Task {
|
||||
await cancelUploadedArchive(fileId, ctrl)
|
||||
await startChatAndDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
|
||||
Task {
|
||||
await cancelUploadedArchive(fileId, ctrl)
|
||||
await MainActor.run {
|
||||
migrationState = .finished(chatDeletion: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteChatAndDismiss() {
|
||||
Task {
|
||||
do {
|
||||
try await deleteChatAsync()
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
migrationState = .finished(chatDeletion: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
try initializeChat(start: false)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
showSettings = false
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error deleting database", error: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startChatAndDismiss(_ dismiss: Bool = true) async {
|
||||
AppChatState.shared.set(.active)
|
||||
do {
|
||||
if m.chatDbChanged {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
} else {
|
||||
try startChat(refreshInvitations: true)
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error starting chat", error: responseError(error))
|
||||
}
|
||||
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
|
||||
if dismiss || m.chatDbStatus != .ok {
|
||||
await MainActor.run {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func urlForTemporaryDatabase() -> URL {
|
||||
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PassphraseConfirmationView: View {
|
||||
@Binding var migrationState: MigrationToState
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var currentKey: String = ""
|
||||
@State private var verifyingPassphrase: Bool = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@Binding var alert: MigrateToAnotherDeviceViewAlert?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
List {
|
||||
chatStoppedView()
|
||||
Section {
|
||||
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
.focused($keyboardVisible)
|
||||
Button(action: {
|
||||
verifyingPassphrase = true
|
||||
hideKeyboard()
|
||||
Task {
|
||||
await verifyDatabasePassphrase(currentKey)
|
||||
verifyingPassphrase = false
|
||||
}
|
||||
}) {
|
||||
settingsRow(useKeychain ? "key" : "lock", color: .secondary) {
|
||||
Text("Verify passphrase")
|
||||
}
|
||||
}
|
||||
.disabled(verifyingPassphrase || currentKey.isEmpty)
|
||||
} header: {
|
||||
Text("Verify database passphrase")
|
||||
} footer: {
|
||||
Text("Confirm that you remember database passphrase to migrate it.")
|
||||
.font(.callout)
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
keyboardVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if verifyingPassphrase {
|
||||
progressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyDatabasePassphrase(_ dbKey: String) async {
|
||||
do {
|
||||
try await testStorageEncryption(key: dbKey)
|
||||
await MainActor.run {
|
||||
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 databaseUrl: URL
|
||||
let processReceivedMsg: (ChatResponse) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
|
||||
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
|
||||
self.ctrl = ctrl
|
||||
self.databaseUrl = databaseUrl
|
||||
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) {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
}
|
||||
logger.debug("processReceivedMsg: \(msg.responseType)")
|
||||
await processReceivedMsg(msg)
|
||||
}
|
||||
if self.receiveMessages {
|
||||
_ = try? await Task.sleep(nanoseconds: 7_500_000)
|
||||
await receiveMsgLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func stopAndCleanUp() {
|
||||
logger.debug("MigrationChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
receiveLoop?.cancel()
|
||||
receiveLoop = nil
|
||||
chat_close_store(ctrl)
|
||||
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_chat.db")
|
||||
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_agent.db")
|
||||
}
|
||||
}
|
||||
|
||||
struct MigrateToAnotherDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MigrateToAnotherDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
if case .connect = selection {
|
||||
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
|
||||
ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
|
||||
.transition(.move(edge: .trailing))
|
||||
}
|
||||
}
|
||||
@@ -284,8 +284,7 @@ private struct InviteView: View {
|
||||
|
||||
private struct ConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
|
||||
@Binding var showQRCodeScanner: Bool
|
||||
@Binding var pastedLink: String
|
||||
@Binding var alert: NewChatViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@@ -295,32 +294,13 @@ private struct ConnectView: View {
|
||||
Section("Paste the link you received") {
|
||||
pasteLinkView()
|
||||
}
|
||||
|
||||
scanCodeView()
|
||||
Section("Or scan QR code") {
|
||||
ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in
|
||||
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 {
|
||||
@@ -351,8 +331,45 @@ private struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func scanCodeView() -> some View {
|
||||
Section("Or scan QR code") {
|
||||
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
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 {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
@@ -396,37 +413,26 @@ private struct ConnectView: View {
|
||||
.disabled(cameraAuthorizationStatus == .restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
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"
|
||||
))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
if allowed { cb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SimpleXInfo: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@State private var showHowItWorks = false
|
||||
@State private var migrationState: MigrationFromState? = nil
|
||||
@State private var migrateFromAnotherDevice: Bool = false
|
||||
var onboarding: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +47,16 @@ struct SimpleXInfo: View {
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
migrationState = nil
|
||||
migrateFromAnotherDevice = true
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -54,9 +67,25 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.onAppear {
|
||||
if m.migrationState != nil {
|
||||
migrationState = m.migrationState?.makeMigrationState()
|
||||
migrateFromAnotherDevice = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $migrateFromAnotherDevice) {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading) {
|
||||
MigrateFromAnotherDevice(migrationState: migrationState ?? .pasteOrScanLink)
|
||||
}
|
||||
.navigationTitle("Migrate here")
|
||||
.background(colorScheme == .light ? Color(uiColor: .tertiarySystemGroupedBackground) : .clear)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHowItWorks) {
|
||||
HowItWorks(onboarding: onboarding)
|
||||
}
|
||||
@@ -87,6 +116,7 @@ struct SimpleXInfo: View {
|
||||
|
||||
struct OnboardingActionButton: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if m.currentUser == nil {
|
||||
@@ -111,6 +141,21 @@ struct OnboardingActionButton: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.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 {
|
||||
|
||||
@@ -58,7 +58,7 @@ struct ConnectDesktopView: View {
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
.modifier(BackButton(label: "Back") {
|
||||
.modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
|
||||
if m.activeRemoteCtrl {
|
||||
alert = .disconnectDesktop(action: .back)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// AppSettings.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 26.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
extension AppSettings {
|
||||
public func importIntoApp() {
|
||||
let def = UserDefaults.standard
|
||||
if var val = networkConfig {
|
||||
// migrating from Android/desktop BUT shouldn't be here ever because it should be changed in migration stage
|
||||
if case .onionViaSocks = val.hostMode {
|
||||
val.hostMode = .publicHost
|
||||
val.requiredHostMode = true
|
||||
}
|
||||
val.socksProxy = nil
|
||||
setNetCfg(val)
|
||||
}
|
||||
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
|
||||
if let val = privacyAcceptImages {
|
||||
privacyAcceptImagesGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
}
|
||||
if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) }
|
||||
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
|
||||
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
|
||||
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
|
||||
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
|
||||
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
|
||||
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
|
||||
if let val = webrtcICEServers { def.setValue(val, forKey: DEFAULT_WEBRTC_ICE_SERVERS) }
|
||||
if let val = confirmRemoteSessions { def.setValue(val, forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS) }
|
||||
if let val = connectRemoteViaMulticast { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) }
|
||||
if let val = connectRemoteViaMulticastAuto { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) }
|
||||
if let val = developerTools { def.setValue(val, forKey: DEFAULT_DEVELOPER_TOOLS) }
|
||||
if let val = confirmDBUpgrades { confirmDBUpgradesGroupDefault.set(val) }
|
||||
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
|
||||
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
|
||||
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
|
||||
}
|
||||
|
||||
public static var current: AppSettings {
|
||||
let def = UserDefaults.standard
|
||||
var c = AppSettings.defaults
|
||||
c.networkConfig = getNetCfg()
|
||||
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
|
||||
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
|
||||
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
||||
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
||||
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
|
||||
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
|
||||
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
c.webrtcICEServers = def.stringArray(forKey: DEFAULT_WEBRTC_ICE_SERVERS)
|
||||
c.confirmRemoteSessions = def.bool(forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS)
|
||||
c.connectRemoteViaMulticast = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST)
|
||||
c.connectRemoteViaMulticastAuto = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO)
|
||||
c.developerTools = def.bool(forKey: DEFAULT_DEVELOPER_TOOLS)
|
||||
c.confirmDBUpgrades = confirmDBUpgradesGroupDefault.get()
|
||||
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
|
||||
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
|
||||
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
|
||||
return c
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ struct ProtocolServerView: View {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Your \(proto) servers") {
|
||||
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
|
||||
server = serverToEdit
|
||||
dismiss()
|
||||
})
|
||||
@@ -117,6 +117,7 @@ struct ProtocolServerView: View {
|
||||
|
||||
struct BackButton: ViewModifier {
|
||||
var label: LocalizedStringKey = "Back"
|
||||
@Binding var disabled: Bool
|
||||
var action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
@@ -130,6 +131,7 @@ struct BackButton: ViewModifier {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ struct ProtocolServersView: View {
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(servers: $servers)
|
||||
}
|
||||
.modifier(BackButton {
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if saveDisabled {
|
||||
dismiss()
|
||||
justOpened = false
|
||||
|
||||
@@ -27,7 +27,7 @@ let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown"
|
||||
let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
|
||||
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
|
||||
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
|
||||
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
|
||||
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
|
||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
|
||||
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
|
||||
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
||||
@@ -51,6 +51,7 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
|
||||
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
|
||||
let DEFAULT_MIGRATION_STAGE = "migrationStage"
|
||||
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
|
||||
let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
|
||||
let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
|
||||
@@ -58,6 +59,8 @@ let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
|
||||
|
||||
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
DEFAULT_LA_NOTICE_SHOWN: false,
|
||||
@@ -93,6 +96,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
|
||||
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
|
||||
]
|
||||
|
||||
// not used anymore
|
||||
@@ -148,10 +152,14 @@ struct SettingsView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@Binding var showSettings: Bool
|
||||
@State private var showProgress: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
settingsView()
|
||||
if showProgress {
|
||||
progressView()
|
||||
}
|
||||
if let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
}
|
||||
@@ -202,9 +210,17 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
settingsRow("desktopcomputer") { Text("Use from desktop") }
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MigrateToAnotherDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
|
||||
.navigationTitle("Migrate device")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("tray.and.arrow.up") { Text("Migrate to another device") }
|
||||
}
|
||||
}
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
|
||||
Section("Settings") {
|
||||
NavigationLink {
|
||||
NotificationsView()
|
||||
@@ -349,6 +365,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
VStack {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||
}
|
||||
|
||||
private enum NotificationAlert {
|
||||
case enable
|
||||
case error(LocalizedStringKey, String)
|
||||
|
||||
@@ -47,7 +47,7 @@ struct UserAddressView: View {
|
||||
userAddressScrollView()
|
||||
} else {
|
||||
userAddressScrollView()
|
||||
.modifier(BackButton {
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if savedAAS == aas {
|
||||
dismiss()
|
||||
} else {
|
||||
|
||||
@@ -640,7 +640,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
|
||||
cleanupDirectFile(aChatItem)
|
||||
return nil
|
||||
case let .sndFileRcvCancelled(_, aChatItem, _):
|
||||
cleanupDirectFile(aChatItem)
|
||||
if let aChatItem = aChatItem {
|
||||
cleanupDirectFile(aChatItem)
|
||||
}
|
||||
return nil
|
||||
case let .sndFileCompleteXFTP(_, aChatItem, _):
|
||||
cleanupFile(aChatItem)
|
||||
|
||||
@@ -57,15 +57,15 @@
|
||||
5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; };
|
||||
5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; };
|
||||
5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */; };
|
||||
5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746D9F2B9F09AD0049D734 /* libffi.a */; };
|
||||
5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA02B9F09AD0049D734 /* libgmpxx.a */; };
|
||||
5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */; };
|
||||
5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */; };
|
||||
5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA32B9F09AD0049D734 /* libgmp.a */; };
|
||||
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
|
||||
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
||||
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
||||
5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD32B99B38B00C72EFF /* libgmp.a */; };
|
||||
5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD42B99B38B00C72EFF /* libgmpxx.a */; };
|
||||
5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD52B99B38B00C72EFF /* libffi.a */; };
|
||||
5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */; };
|
||||
5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */; };
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
|
||||
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; };
|
||||
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; };
|
||||
@@ -190,6 +190,9 @@
|
||||
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
|
||||
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
|
||||
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
|
||||
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.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 */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
|
||||
@@ -326,15 +329,15 @@
|
||||
5C6D183229E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = "pl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5C6D183329E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = "<group>"; };
|
||||
5C746D9F2B9F09AD0049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C746DA02B9F09AD0049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a"; sourceTree = "<group>"; };
|
||||
5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5C746DA32B9F09AD0049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
|
||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
||||
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
||||
5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = "<group>"; };
|
||||
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
@@ -483,6 +486,9 @@
|
||||
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>"; };
|
||||
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
|
||||
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.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>"; };
|
||||
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; };
|
||||
@@ -524,13 +530,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */,
|
||||
5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */,
|
||||
5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */,
|
||||
5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */,
|
||||
5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */,
|
||||
5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */,
|
||||
5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */,
|
||||
5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -563,6 +569,7 @@
|
||||
5CB924DD27A8622200ACCCDD /* NewChat */,
|
||||
5CFA59C22860B04D00863A68 /* Database */,
|
||||
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
|
||||
8C7D94982B8894D300B7B9E1 /* Migration */,
|
||||
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
|
||||
5CB924DF27A8678B00ACCCDD /* UserSettings */,
|
||||
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
|
||||
@@ -592,11 +599,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C777BD52B99B38B00C72EFF /* libffi.a */,
|
||||
5C777BD32B99B38B00C72EFF /* libgmp.a */,
|
||||
5C777BD42B99B38B00C72EFF /* libgmpxx.a */,
|
||||
5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */,
|
||||
5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */,
|
||||
5C746D9F2B9F09AD0049D734 /* libffi.a */,
|
||||
5C746DA32B9F09AD0049D734 /* libgmp.a */,
|
||||
5C746DA02B9F09AD0049D734 /* libgmpxx.a */,
|
||||
5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */,
|
||||
5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -776,6 +783,7 @@
|
||||
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */,
|
||||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
|
||||
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
|
||||
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
@@ -903,6 +911,15 @@
|
||||
path = Group;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C7D94982B8894D300B7B9E1 /* Migration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */,
|
||||
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */,
|
||||
);
|
||||
path = Migration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
@@ -1134,6 +1151,7 @@
|
||||
5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */,
|
||||
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */,
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */,
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
|
||||
@@ -1189,6 +1207,7 @@
|
||||
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */,
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
|
||||
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
|
||||
@@ -1230,6 +1249,7 @@
|
||||
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */,
|
||||
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
|
||||
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
|
||||
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */,
|
||||
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
|
||||
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
|
||||
|
||||
@@ -54,6 +54,33 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
|
||||
return result
|
||||
}
|
||||
|
||||
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (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 = confirmation.rawValue.cString(using: .utf8)!
|
||||
let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)!
|
||||
return (dbMigrationResult(fromCString(cjson)), temporaryController)
|
||||
}
|
||||
|
||||
public func chatInitControllerRemovingDatabases() {
|
||||
let dbPath = getAppDatabasePath().path
|
||||
let dbKey = randomDatabasePassword()
|
||||
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
|
||||
var cPath = dbPath.cString(using: .utf8)!
|
||||
var cKey = dbKey.cString(using: .utf8)!
|
||||
var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)!
|
||||
chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController)
|
||||
// We need only controller, not databases
|
||||
let fm = FileManager.default
|
||||
try? fm.removeItem(atPath: dbPath + CHAT_DB)
|
||||
try? fm.removeItem(atPath: dbPath + AGENT_DB)
|
||||
}
|
||||
|
||||
|
||||
public func chatCloseStore() {
|
||||
let err = fromCString(chat_close_store(getChatCtrl()))
|
||||
if err != "" {
|
||||
@@ -73,17 +100,17 @@ public func resetChatCtrl() {
|
||||
migrationResult = nil
|
||||
}
|
||||
|
||||
public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse {
|
||||
public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
||||
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))
|
||||
}
|
||||
|
||||
// in microseconds
|
||||
let MESSAGE_TIMEOUT: Int32 = 15_000_000
|
||||
|
||||
public func recvSimpleXMsg() -> ChatResponse? {
|
||||
if let cjson = chat_recv_msg_wait(getChatCtrl(), MESSAGE_TIMEOUT) {
|
||||
public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil) -> ChatResponse? {
|
||||
if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), MESSAGE_TIMEOUT) {
|
||||
let s = fromCString(cjson)
|
||||
return s == "" ? nil : chatResponse(s)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ public enum ChatCommand {
|
||||
case apiImportArchive(config: ArchiveConfig)
|
||||
case apiDeleteStorage
|
||||
case apiStorageEncryption(config: DBEncryptionConfig)
|
||||
case testStorageEncryption(key: String)
|
||||
case apiSaveSettings(settings: AppSettings)
|
||||
case apiGetSettings(settings: AppSettings)
|
||||
case apiGetChats(userId: Int64)
|
||||
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
|
||||
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
|
||||
@@ -132,6 +135,9 @@ public enum ChatCommand {
|
||||
case listRemoteCtrls
|
||||
case stopRemoteCtrl
|
||||
case deleteRemoteCtrl(remoteCtrlId: Int64)
|
||||
case apiUploadStandaloneFile(userId: Int64, file: CryptoFile)
|
||||
case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile)
|
||||
case apiStandaloneFileInfo(url: String)
|
||||
// misc
|
||||
case showVersion
|
||||
case string(String)
|
||||
@@ -170,6 +176,9 @@ public enum ChatCommand {
|
||||
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
|
||||
case .apiDeleteStorage: return "/_db delete"
|
||||
case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))"
|
||||
case let .testStorageEncryption(key): return "/db test key \(key)"
|
||||
case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))"
|
||||
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
|
||||
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)" +
|
||||
(search == "" ? "" : " search=\(search)")
|
||||
@@ -282,6 +291,9 @@ public enum ChatCommand {
|
||||
case .listRemoteCtrls: return "/list remote ctrls"
|
||||
case .stopRemoteCtrl: return "/stop remote ctrl"
|
||||
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 let .apiStandaloneFileInfo(link): return "/_download info \(link)"
|
||||
case .showVersion: return "/version"
|
||||
case let .string(str): return str
|
||||
}
|
||||
@@ -316,6 +328,9 @@ public enum ChatCommand {
|
||||
case .apiImportArchive: return "apiImportArchive"
|
||||
case .apiDeleteStorage: return "apiDeleteStorage"
|
||||
case .apiStorageEncryption: return "apiStorageEncryption"
|
||||
case .testStorageEncryption: return "testStorageEncryption"
|
||||
case .apiSaveSettings: return "apiSaveSettings"
|
||||
case .apiGetSettings: return "apiGetSettings"
|
||||
case .apiGetChats: return "apiGetChats"
|
||||
case .apiGetChat: return "apiGetChat"
|
||||
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
|
||||
@@ -408,6 +423,9 @@ public enum ChatCommand {
|
||||
case .listRemoteCtrls: return "listRemoteCtrls"
|
||||
case .stopRemoteCtrl: return "stopRemoteCtrl"
|
||||
case .deleteRemoteCtrl: return "deleteRemoteCtrl"
|
||||
case .apiUploadStandaloneFile: return "apiUploadStandaloneFile"
|
||||
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
|
||||
case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
|
||||
case .showVersion: return "showVersion"
|
||||
case .string: return "console command"
|
||||
}
|
||||
@@ -442,6 +460,8 @@ public enum ChatCommand {
|
||||
return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd))
|
||||
case let .apiDeleteUser(userId, delSMPQueues, viewPwd):
|
||||
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
|
||||
case let .testStorageEncryption(key):
|
||||
return .testStorageEncryption(key: obfuscate(key))
|
||||
default: return self
|
||||
}
|
||||
}
|
||||
@@ -590,20 +610,28 @@ public enum ChatResponse: Decodable, Error {
|
||||
// receiving file events
|
||||
case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
|
||||
case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer)
|
||||
case rcvFileStart(user: UserRef, chatItem: AChatItem)
|
||||
case rcvFileProgressXFTP(user: UserRef, chatItem: AChatItem, receivedSize: Int64, totalSize: Int64)
|
||||
case standaloneFileInfo(fileMeta: MigrationFileLinkData?)
|
||||
case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer)
|
||||
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 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 rcvFileError(user: UserRef, chatItem: AChatItem)
|
||||
case rcvFileError(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer)
|
||||
// sending file events
|
||||
case sndFileStart(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 sndFileProgressXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64)
|
||||
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
|
||||
case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer])
|
||||
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 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
|
||||
case callInvitation(callInvitation: RcvCallInvitation)
|
||||
case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool)
|
||||
@@ -632,6 +660,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatCmdError(user_: UserRef?, chatError: ChatError)
|
||||
case chatError(user_: UserRef?, chatError: ChatError)
|
||||
case archiveImported(archiveErrors: [ArchiveError])
|
||||
case appSettings(appSettings: AppSettings)
|
||||
|
||||
public var responseType: String {
|
||||
get {
|
||||
@@ -744,18 +773,26 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .newMemberContactReceivedInv: return "newMemberContactReceivedInv"
|
||||
case .rcvFileAccepted: return "rcvFileAccepted"
|
||||
case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled"
|
||||
case .standaloneFileInfo: return "standaloneFileInfo"
|
||||
case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated"
|
||||
case .rcvFileStart: return "rcvFileStart"
|
||||
case .rcvFileProgressXFTP: return "rcvFileProgressXFTP"
|
||||
case .rcvFileComplete: return "rcvFileComplete"
|
||||
case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete"
|
||||
case .rcvFileCancelled: return "rcvFileCancelled"
|
||||
case .rcvFileSndCancelled: return "rcvFileSndCancelled"
|
||||
case .rcvFileError: return "rcvFileError"
|
||||
case .sndFileStart: return "sndFileStart"
|
||||
case .sndFileComplete: return "sndFileComplete"
|
||||
case .sndFileCancelled: return "sndFileCancelled"
|
||||
case .sndFileRcvCancelled: return "sndFileRcvCancelled"
|
||||
case .sndStandaloneFileCreated: return "sndStandaloneFileCreated"
|
||||
case .sndFileStartXFTP: return "sndFileStartXFTP"
|
||||
case .sndFileProgressXFTP: return "sndFileProgressXFTP"
|
||||
case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP"
|
||||
case .sndFileRcvCancelled: return "sndFileRcvCancelled"
|
||||
case .sndFileCompleteXFTP: return "sndFileCompleteXFTP"
|
||||
case .sndStandaloneFileComplete: return "sndStandaloneFileComplete"
|
||||
case .sndFileCancelledXFTP: return "sndFileCancelledXFTP"
|
||||
case .sndFileError: return "sndFileError"
|
||||
case .callInvitation: return "callInvitation"
|
||||
case .callOffer: return "callOffer"
|
||||
@@ -781,6 +818,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatCmdError: return "chatCmdError"
|
||||
case .chatError: return "chatError"
|
||||
case .archiveImported: return "archiveImported"
|
||||
case .appSettings: return "appSettings"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,19 +934,27 @@ public enum ChatResponse: Decodable, Error {
|
||||
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 .rcvFileAcceptedSndCancelled: return noDetails
|
||||
case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta)
|
||||
case .rcvStandaloneFileCreated: return noDetails
|
||||
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 .rcvFileCancelled(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 .sndFileComplete(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 .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 .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 .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))")
|
||||
@@ -933,6 +979,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .chatError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
|
||||
case let .appSettings(appSettings): return String(describing: appSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1534,7 +1581,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
|
||||
public static var values: [NotificationsMode] = [.instant, .periodic, .off]
|
||||
}
|
||||
|
||||
public enum NotificationPreviewMode: String, SelectableItem {
|
||||
public enum NotificationPreviewMode: String, SelectableItem, Codable {
|
||||
case hidden
|
||||
case contact
|
||||
case message
|
||||
@@ -1734,6 +1781,7 @@ public enum StoreError: Decodable {
|
||||
case fileIdNotFoundBySharedMsgId(sharedMsgId: String)
|
||||
case sndFileNotFoundXFTP(agentSndFileId: String)
|
||||
case rcvFileNotFoundXFTP(agentRcvFileId: String)
|
||||
case extraFileDescrNotFoundXFTP(fileId: Int64)
|
||||
case connectionNotFound(agentConnId: String)
|
||||
case connectionNotFoundById(connId: Int64)
|
||||
case connectionNotFoundByMemberId(groupMemberId: Int64)
|
||||
@@ -1895,3 +1943,147 @@ public enum RemoteCtrlError: Decodable {
|
||||
case badVersion(appVersion: String)
|
||||
// case protocolError(protocolError: RemoteProtocolError)
|
||||
}
|
||||
|
||||
public struct MigrationFileLinkData: Codable {
|
||||
let networkConfig: NetworkConfig?
|
||||
|
||||
public init(networkConfig: NetworkConfig) {
|
||||
self.networkConfig = networkConfig
|
||||
}
|
||||
|
||||
public struct NetworkConfig: Codable {
|
||||
let socksProxy: String?
|
||||
let hostMode: HostMode?
|
||||
let requiredHostMode: Bool?
|
||||
|
||||
public init(socksProxy: String?, hostMode: HostMode?, requiredHostMode: Bool?) {
|
||||
self.socksProxy = socksProxy
|
||||
self.hostMode = hostMode
|
||||
self.requiredHostMode = requiredHostMode
|
||||
}
|
||||
|
||||
public func transformToPlatformSupported() -> NetworkConfig {
|
||||
return if let hostMode, let requiredHostMode {
|
||||
NetworkConfig(
|
||||
socksProxy: nil,
|
||||
hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode,
|
||||
requiredHostMode: requiredHostMode
|
||||
)
|
||||
} else { self }
|
||||
}
|
||||
}
|
||||
|
||||
public func addToLink(link: String) -> String {
|
||||
"\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)"
|
||||
}
|
||||
|
||||
public static func readFromLink(link: String) -> MigrationFileLinkData? {
|
||||
// standaloneFileInfo(link)
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct AppSettings: Codable, Equatable {
|
||||
public var networkConfig: NetCfg? = nil
|
||||
public var privacyEncryptLocalFiles: Bool? = nil
|
||||
public var privacyAcceptImages: Bool? = nil
|
||||
public var privacyLinkPreviews: Bool? = nil
|
||||
public var privacyShowChatPreviews: Bool? = nil
|
||||
public var privacySaveLastDraft: Bool? = nil
|
||||
public var privacyProtectScreen: Bool? = nil
|
||||
public var notificationMode: AppSettingsNotificationMode? = nil
|
||||
public var notificationPreviewMode: NotificationPreviewMode? = nil
|
||||
public var webrtcPolicyRelay: Bool? = nil
|
||||
public var webrtcICEServers: [String]? = nil
|
||||
public var confirmRemoteSessions: Bool? = nil
|
||||
public var connectRemoteViaMulticast: Bool? = nil
|
||||
public var connectRemoteViaMulticastAuto: Bool? = nil
|
||||
public var developerTools: Bool? = nil
|
||||
public var confirmDBUpgrades: Bool? = nil
|
||||
public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
|
||||
public var iosCallKitEnabled: Bool? = nil
|
||||
public var iosCallKitCallsInRecents: Bool? = nil
|
||||
|
||||
public func prepareForExport() -> AppSettings {
|
||||
var empty = AppSettings()
|
||||
let def = AppSettings.defaults
|
||||
if networkConfig != def.networkConfig { empty.networkConfig = networkConfig }
|
||||
if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
|
||||
if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
|
||||
if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
|
||||
if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
|
||||
if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft }
|
||||
if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen }
|
||||
if notificationMode != def.notificationMode { empty.notificationMode = notificationMode }
|
||||
if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode }
|
||||
if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay }
|
||||
if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers }
|
||||
if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions }
|
||||
if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast }
|
||||
if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto }
|
||||
if developerTools != def.developerTools { empty.developerTools = developerTools }
|
||||
if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades }
|
||||
if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
|
||||
if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
|
||||
if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
|
||||
return empty
|
||||
}
|
||||
|
||||
public static var defaults: AppSettings {
|
||||
AppSettings (
|
||||
networkConfig: NetCfg.defaults,
|
||||
privacyEncryptLocalFiles: true,
|
||||
privacyAcceptImages: true,
|
||||
privacyLinkPreviews: true,
|
||||
privacyShowChatPreviews: true,
|
||||
privacySaveLastDraft: true,
|
||||
privacyProtectScreen: false,
|
||||
notificationMode: AppSettingsNotificationMode.instant,
|
||||
notificationPreviewMode: NotificationPreviewMode.message,
|
||||
webrtcPolicyRelay: true,
|
||||
webrtcICEServers: [],
|
||||
confirmRemoteSessions: false,
|
||||
connectRemoteViaMulticast: true,
|
||||
connectRemoteViaMulticastAuto: true,
|
||||
developerTools: false,
|
||||
confirmDBUpgrades: false,
|
||||
androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
|
||||
iosCallKitEnabled: true,
|
||||
iosCallKitCallsInRecents: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppSettingsNotificationMode: String, Codable {
|
||||
case off
|
||||
case periodic
|
||||
case instant
|
||||
|
||||
public func toNotificationsMode() -> NotificationsMode {
|
||||
switch self {
|
||||
case .instant: .instant
|
||||
case .periodic: .periodic
|
||||
case .off: .off
|
||||
}
|
||||
}
|
||||
|
||||
public static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode {
|
||||
switch mode {
|
||||
case .instant: .instant
|
||||
case .periodic: .periodic
|
||||
case .off: .off
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//public enum NotificationPreviewMode: Codable {
|
||||
// case hidden
|
||||
// case contact
|
||||
// case message
|
||||
//}
|
||||
|
||||
public enum AppSettingsLockScreenCalls: String, Codable {
|
||||
case disable
|
||||
case show
|
||||
case accept
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun"
|
||||
let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode"
|
||||
public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used
|
||||
public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used
|
||||
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
|
||||
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
|
||||
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
|
||||
public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles"
|
||||
@@ -36,7 +37,7 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl"
|
||||
let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt"
|
||||
public let GROUP_DEFAULT_INCOGNITO = "incognito"
|
||||
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_CALL_KIT_ENABLED = "callKitEnabled"
|
||||
public let GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED = "pqExperimentalEnabled"
|
||||
@@ -169,6 +170,7 @@ public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>(
|
||||
|
||||
public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO)
|
||||
|
||||
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
|
||||
public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
|
||||
public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES)
|
||||
|
||||
@@ -2784,23 +2784,23 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
|
||||
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
|
||||
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
|
||||
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo)
|
||||
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo)
|
||||
case .sndGroupE2EEInfo: return e2eeInfoNoPQText
|
||||
case .rcvGroupE2EEInfo: return e2eeInfoNoPQText
|
||||
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
|
||||
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
|
||||
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
|
||||
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
|
||||
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func directE2EEInfoToText(_ e2eeInfo: E2EEInfo) -> String {
|
||||
private func directE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String {
|
||||
e2eeInfo.pqEnabled
|
||||
? NSLocalizedString("This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.", comment: "E2EE info chat item")
|
||||
: e2eeInfoNoPQText
|
||||
? NSLocalizedString("This chat is protected by quantum resistant end-to-end encryption.", comment: "E2EE info chat item")
|
||||
: e2eeInfoNoPQStr
|
||||
}
|
||||
|
||||
private var e2eeInfoNoPQText: String {
|
||||
NSLocalizedString("This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.", comment: "E2EE info chat item")
|
||||
private var e2eeInfoNoPQStr: String {
|
||||
NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item")
|
||||
}
|
||||
|
||||
static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String {
|
||||
@@ -3410,11 +3410,14 @@ public struct SndFileTransfer: Decodable {
|
||||
}
|
||||
|
||||
public struct RcvFileTransfer: Decodable {
|
||||
|
||||
public let fileId: Int64
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -28,9 +28,9 @@ public let MAX_FILE_SIZE_SMP: Int64 = 8000000
|
||||
|
||||
public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300)
|
||||
|
||||
private let CHAT_DB: String = "_chat.db"
|
||||
let CHAT_DB: String = "_chat.db"
|
||||
|
||||
private let AGENT_DB: String = "_agent.db"
|
||||
let AGENT_DB: String = "_agent.db"
|
||||
|
||||
private let CHAT_DB_BAK: String = "_chat.db.bak"
|
||||
|
||||
@@ -83,6 +83,7 @@ public func deleteAppDatabaseAndFiles() {
|
||||
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
|
||||
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
|
||||
try? fm.removeItem(at: getTempFilesDirectory())
|
||||
try? fm.removeItem(at: getMigrationTempFilesDirectory())
|
||||
try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
|
||||
deleteAppFiles()
|
||||
_ = kcDatabasePassword.remove()
|
||||
@@ -183,6 +184,10 @@ public func getTempFilesDirectory() -> URL {
|
||||
getAppDirectory().appendingPathComponent("temp_files", isDirectory: true)
|
||||
}
|
||||
|
||||
public func getMigrationTempFilesDirectory() -> URL {
|
||||
getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true)
|
||||
}
|
||||
|
||||
public func getAppFilesDirectory() -> URL {
|
||||
getAppDirectory().appendingPathComponent("app_files", isDirectory: true)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.NtfManager
|
||||
@@ -18,8 +24,7 @@ import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.ui.theme.DefaultTheme
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import chat.simplex.common.views.call.activeCallDestroyWebView
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
@@ -65,7 +70,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
tmpDir.deleteRecursively()
|
||||
tmpDir.mkdir()
|
||||
|
||||
if (DatabaseUtils.ksSelfDestructPassword.get() == null) {
|
||||
// Present screen for continue migration if it wasn't finished yet
|
||||
if (chatModel.migrationState.value != null) {
|
||||
// It's important, otherwise, user may be locked in undefined state
|
||||
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
} else if (DatabaseUtils.ksAppPassword.get() == null || DatabaseUtils.ksSelfDestructPassword.get() == null) {
|
||||
initChatControllerAndRunMigrations()
|
||||
}
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
|
||||
@@ -282,6 +291,21 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
activeCallDestroyWebView()
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
onDispose {
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
|
||||
if (SimplexService.isBackgroundRestricted()) {
|
||||
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
|
||||
|
||||
+11
-4
@@ -2,6 +2,7 @@ package chat.simplex.common.views.database
|
||||
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import TextIconSpaced
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -22,8 +23,9 @@ actual fun SavePassphraseSetting(
|
||||
useKeychain: Boolean,
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp,
|
||||
enabled: Boolean,
|
||||
smallPadding: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
@@ -33,7 +35,11 @@ actual fun SavePassphraseSetting(
|
||||
stringResource(MR.strings.save_passphrase_in_keychain),
|
||||
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
if (smallPadding) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
} else {
|
||||
TextIconSpaced(false)
|
||||
}
|
||||
Text(
|
||||
stringResource(MR.strings.save_passphrase_in_keychain),
|
||||
Modifier.padding(end = 24.dp),
|
||||
@@ -43,7 +49,7 @@ actual fun SavePassphraseSetting(
|
||||
DefaultSwitch(
|
||||
checked = useKeychain,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter(
|
||||
chatDbEncrypted: Boolean?,
|
||||
storedKey: MutableState<Boolean>,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
) {
|
||||
if (chatDbEncrypted == false) {
|
||||
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
|
||||
} else if (useKeychain.value) {
|
||||
if (storedKey.value) {
|
||||
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
|
||||
if (initialRandomDBPassphrase.value) {
|
||||
if (initialRandomDBPassphrase.value && !migration) {
|
||||
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
|
||||
} else {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
|
||||
|
||||
@@ -110,6 +110,13 @@ fun MainScreen() {
|
||||
val localUserCreated = chatModel.localUserCreated.value
|
||||
var showInitializationView by remember { mutableStateOf(false) }
|
||||
when {
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo && chatModel.migrationState.value != null -> {
|
||||
// In migration process. Nothing should interrupt it, that's why it's the first branch in when()
|
||||
SimpleXInfo(chatModel, onboarding = true)
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
}
|
||||
chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress))
|
||||
chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database))
|
||||
showChatDatabaseError -> {
|
||||
|
||||
+20
-10
@@ -13,6 +13,7 @@ import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
@@ -104,6 +105,8 @@ object ChatModel {
|
||||
// currently showing invitation
|
||||
val showingInvitation = mutableStateOf(null as ShowingInvitation?)
|
||||
|
||||
val migrationState: MutableState<MigrationFromAnotherDeviceState?> by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) }
|
||||
|
||||
var draft = mutableStateOf(null as ComposeState?)
|
||||
var draftChatId = mutableStateOf(null as String?)
|
||||
|
||||
@@ -2328,10 +2331,10 @@ sealed class CIContent: ItemContent {
|
||||
is SndModerated -> generalGetString(MR.strings.moderated_description)
|
||||
is RcvModerated -> generalGetString(MR.strings.moderated_description)
|
||||
is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description)
|
||||
is SndDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo)
|
||||
is RcvDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo)
|
||||
is SndGroupE2EEInfo -> e2eeInfoNoPQText
|
||||
is RcvGroupE2EEInfo -> e2eeInfoNoPQText
|
||||
is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo)
|
||||
is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo)
|
||||
is SndGroupE2EEInfo -> e2eeInfoNoPQStr
|
||||
is RcvGroupE2EEInfo -> e2eeInfoNoPQStr
|
||||
is InvalidJSON -> "invalid data"
|
||||
}
|
||||
|
||||
@@ -2350,14 +2353,14 @@ sealed class CIContent: ItemContent {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun directE2EEInfoToText(e2EEInfo: E2EEInfo): String =
|
||||
fun directE2EEInfoStr(e2EEInfo: E2EEInfo): String =
|
||||
if (e2EEInfo.pqEnabled) {
|
||||
generalGetString(MR.strings.e2ee_info_pq)
|
||||
generalGetString(MR.strings.e2ee_info_pq_short)
|
||||
} else {
|
||||
e2eeInfoNoPQText
|
||||
e2eeInfoNoPQStr
|
||||
}
|
||||
|
||||
private val e2eeInfoNoPQText: String = generalGetString(MR.strings.e2ee_info_no_pq)
|
||||
private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short)
|
||||
|
||||
fun featureText(feature: Feature, enabled: String, param: Int?): String =
|
||||
if (feature.hasParam) {
|
||||
@@ -2973,10 +2976,17 @@ enum class FormatColor(val color: String) {
|
||||
class SndFileTransfer() {}
|
||||
|
||||
@Serializable
|
||||
class RcvFileTransfer() {}
|
||||
data class RcvFileTransfer(
|
||||
val fileId: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class FileTransferMeta() {}
|
||||
data class FileTransferMeta(
|
||||
val fileId: Long,
|
||||
val fileName: String,
|
||||
val filePath: String,
|
||||
val fileSize: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class CICallStatus {
|
||||
|
||||
+391
-60
@@ -4,12 +4,15 @@ import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import chat.simplex.common.model.ChatController.getNetCfg
|
||||
import chat.simplex.common.model.ChatController.setNetCfg
|
||||
import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.migration.MigrationFileLinkData
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import com.charleskorn.kaml.Yaml
|
||||
@@ -144,6 +147,7 @@ class AppPreferences {
|
||||
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
|
||||
|
||||
val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } }
|
||||
val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null)
|
||||
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
|
||||
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
|
||||
val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
|
||||
@@ -177,6 +181,11 @@ class AppPreferences {
|
||||
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
|
||||
|
||||
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
|
||||
|
||||
|
||||
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
|
||||
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
|
||||
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
@@ -277,6 +286,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
|
||||
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
|
||||
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
|
||||
const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage"
|
||||
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
|
||||
private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped"
|
||||
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
|
||||
@@ -326,6 +336,9 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
|
||||
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
|
||||
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
|
||||
|
||||
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
|
||||
private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +415,16 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startChatWithTemporaryDatabase(ctrl: ChatCtrl, netCfg: NetCfg): User? {
|
||||
Log.d(TAG, "startChatWithTemporaryDatabase")
|
||||
val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl)
|
||||
apiSetNetworkConfig(netCfg, ctrl)
|
||||
apiSetTempFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
|
||||
apiSetFilesFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
|
||||
apiStartChat(ctrl)
|
||||
return migrationActiveUser
|
||||
}
|
||||
|
||||
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
|
||||
try {
|
||||
changeActiveUser_(rhId, toUserId, viewPwd)
|
||||
@@ -478,8 +501,8 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendCmd(rhId: Long?, cmd: CC): CR {
|
||||
val ctrl = ctrl ?: throw Exception("Controller is not initialized")
|
||||
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR {
|
||||
val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized")
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val c = cmd.cmdString
|
||||
@@ -496,7 +519,7 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
private fun recvMsg(ctrl: ChatCtrl): APIResponse? {
|
||||
fun recvMsg(ctrl: ChatCtrl): APIResponse? {
|
||||
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
|
||||
return if (json == "") {
|
||||
null
|
||||
@@ -509,8 +532,8 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiGetActiveUser(rh: Long?): User? {
|
||||
val r = sendCmd(rh, CC.ShowActiveUser())
|
||||
suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? {
|
||||
val r = sendCmd(rh, CC.ShowActiveUser(), ctrl)
|
||||
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
|
||||
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
|
||||
if (rh == null) {
|
||||
@@ -519,8 +542,8 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? {
|
||||
val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp))
|
||||
suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? {
|
||||
val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp), ctrl)
|
||||
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
|
||||
else if (
|
||||
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName ||
|
||||
@@ -598,8 +621,8 @@ object ChatController {
|
||||
throw Exception("failed to delete the user ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiStartChat(): Boolean {
|
||||
val r = sendCmd(null, CC.StartChat(mainApp = true))
|
||||
suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean {
|
||||
val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl)
|
||||
when (r) {
|
||||
is CR.ChatStarted -> return true
|
||||
is CR.ChatRunning -> return false
|
||||
@@ -615,14 +638,14 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiSetTempFolder(tempFolder: String) {
|
||||
val r = sendCmd(null, CC.SetTempFolder(tempFolder))
|
||||
suspend fun apiSetTempFolder(tempFolder: String, ctrl: ChatCtrl? = null) {
|
||||
val r = sendCmd(null, CC.SetTempFolder(tempFolder), ctrl)
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("failed to set temp folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetFilesFolder(filesFolder: String) {
|
||||
val r = sendCmd(null, CC.SetFilesFolder(filesFolder))
|
||||
suspend fun apiSetFilesFolder(filesFolder: String, ctrl: ChatCtrl? = null) {
|
||||
val r = sendCmd(null, CC.SetFilesFolder(filesFolder), ctrl)
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
|
||||
}
|
||||
@@ -635,6 +658,18 @@ object ChatController {
|
||||
|
||||
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable))
|
||||
|
||||
suspend fun apiSaveAppSettings(settings: AppSettings) {
|
||||
val r = sendCmd(null, CC.ApiSaveSettings(settings))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Error("failed to set app settings: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiGetAppSettings(settings: AppSettings): AppSettings {
|
||||
val r = sendCmd(null, CC.ApiGetSettings(settings))
|
||||
if (r is CR.AppSettingsR) return r.appSettings
|
||||
throw Error("failed to get app settings: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable))
|
||||
|
||||
suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? {
|
||||
@@ -669,6 +704,11 @@ object ChatController {
|
||||
throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean {
|
||||
val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl)
|
||||
return r is CR.CmdOk
|
||||
}
|
||||
|
||||
suspend fun apiGetChats(rh: Long?): List<Chat> {
|
||||
val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() }
|
||||
val r = sendCmd(rh, CC.ApiGetChats(userId))
|
||||
@@ -805,8 +845,8 @@ object ChatController {
|
||||
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean {
|
||||
val r = sendCmd(null, CC.APISetNetworkConfig(cfg))
|
||||
suspend fun apiSetNetworkConfig(cfg: NetCfg, ctrl: ChatCtrl? = null): Boolean {
|
||||
val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl)
|
||||
return when (r) {
|
||||
is CR.CmdOk -> true
|
||||
else -> {
|
||||
@@ -1236,6 +1276,36 @@ object ChatController {
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair<FileTransferMeta?, String?> {
|
||||
val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl)
|
||||
return if (r is CR.SndStandaloneFileCreated) {
|
||||
r.fileTransferMeta to null
|
||||
} else {
|
||||
Log.e(TAG, "uploadStandaloneFile error: $r")
|
||||
null to r.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair<RcvFileTransfer?, String?> {
|
||||
val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl)
|
||||
return if (r is CR.RcvStandaloneFileCreated) {
|
||||
r.rcvFileTransfer to null
|
||||
} else {
|
||||
Log.e(TAG, "downloadStandaloneFile error: $r")
|
||||
null to r.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? {
|
||||
val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl)
|
||||
return if (r is CR.StandaloneFileInfo) {
|
||||
r.fileMeta
|
||||
} else {
|
||||
Log.e(TAG, "standaloneFileInfo error: $r")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
|
||||
// -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected
|
||||
val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline))
|
||||
@@ -1274,11 +1344,11 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? {
|
||||
val r = sendCmd(rh, CC.CancelFile(fileId))
|
||||
suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? {
|
||||
val r = sendCmd(rh, CC.CancelFile(fileId), ctrl)
|
||||
return when (r) {
|
||||
is CR.SndFileCancelled -> r.chatItem
|
||||
is CR.RcvFileCancelled -> r.chatItem
|
||||
is CR.SndFileCancelled -> r.chatItem_
|
||||
is CR.RcvFileCancelled -> r.chatItem_
|
||||
else -> {
|
||||
Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}")
|
||||
null
|
||||
@@ -1565,8 +1635,8 @@ object ChatController {
|
||||
|
||||
suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId))
|
||||
|
||||
private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean {
|
||||
val r = sendCmd(rh, cmd)
|
||||
private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean {
|
||||
val r = sendCmd(rh, cmd, ctrl)
|
||||
val ok = r is CR.CmdOk
|
||||
if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r)
|
||||
return ok
|
||||
@@ -1856,11 +1926,16 @@ object ChatController {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
cleanupFile(r.chatItem)
|
||||
}
|
||||
is CR.RcvFileProgressXFTP ->
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
is CR.RcvFileProgressXFTP -> {
|
||||
if (r.chatItem_ != null) {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
|
||||
}
|
||||
}
|
||||
is CR.RcvFileError -> {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
cleanupFile(r.chatItem)
|
||||
if (r.chatItem_ != null) {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
|
||||
cleanupFile(r.chatItem_)
|
||||
}
|
||||
}
|
||||
is CR.SndFileStart ->
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
@@ -1869,18 +1944,25 @@ object ChatController {
|
||||
cleanupDirectFile(r.chatItem)
|
||||
}
|
||||
is CR.SndFileRcvCancelled -> {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
cleanupDirectFile(r.chatItem)
|
||||
if (r.chatItem_ != null) {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
|
||||
cleanupDirectFile(r.chatItem_)
|
||||
}
|
||||
}
|
||||
is CR.SndFileProgressXFTP -> {
|
||||
if (r.chatItem_ != null) {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
|
||||
}
|
||||
}
|
||||
is CR.SndFileProgressXFTP ->
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
is CR.SndFileCompleteXFTP -> {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
cleanupFile(r.chatItem)
|
||||
}
|
||||
is CR.SndFileError -> {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
|
||||
cleanupFile(r.chatItem)
|
||||
if (r.chatItem_ != null) {
|
||||
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
|
||||
cleanupFile(r.chatItem_)
|
||||
}
|
||||
}
|
||||
is CR.CallInvitation -> {
|
||||
chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId))
|
||||
@@ -2249,21 +2331,13 @@ object ChatController {
|
||||
|
||||
class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
|
||||
val set: (T) -> Unit
|
||||
private val _state: MutableState<T> by lazy { mutableStateOf(get()) }
|
||||
val state: State<T> by lazy { _state }
|
||||
private val _state: MutableState<T> = mutableStateOf(get())
|
||||
val state: State<T> = _state
|
||||
|
||||
init {
|
||||
this.set = { value ->
|
||||
set(value)
|
||||
try {
|
||||
_state.value = value
|
||||
} catch (e: IllegalStateException) {
|
||||
// Can be `Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied`
|
||||
Log.i(TAG, e.stackTraceToString())
|
||||
withApi {
|
||||
_state.value = value
|
||||
}
|
||||
}
|
||||
_state.value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2295,6 +2369,9 @@ sealed class CC {
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiDeleteStorage: CC()
|
||||
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
|
||||
class TestStorageEncryption(val key: String): CC()
|
||||
class ApiSaveSettings(val settings: AppSettings): CC()
|
||||
class ApiGetSettings(val settings: AppSettings): CC()
|
||||
class ApiGetChats(val userId: Long): CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
|
||||
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
|
||||
@@ -2388,6 +2465,9 @@ sealed class CC {
|
||||
class ListRemoteCtrls(): CC()
|
||||
class StopRemoteCtrl(): CC()
|
||||
class DeleteRemoteCtrl(val remoteCtrlId: Long): CC()
|
||||
class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC()
|
||||
class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC()
|
||||
class ApiStandaloneFileInfo(val url: String): CC()
|
||||
// misc
|
||||
class ShowVersion(): CC()
|
||||
|
||||
@@ -2426,6 +2506,9 @@ sealed class CC {
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
is ApiDeleteStorage -> "/_db delete"
|
||||
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
|
||||
is TestStorageEncryption -> "/db test key $key"
|
||||
is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}"
|
||||
is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}"
|
||||
is ApiGetChats -> "/_get chats $userId pcc=on"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
|
||||
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
|
||||
@@ -2533,6 +2616,9 @@ sealed class CC {
|
||||
is ListRemoteCtrls -> "/list remote ctrls"
|
||||
is StopRemoteCtrl -> "/stop remote ctrl"
|
||||
is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId"
|
||||
is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}"
|
||||
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
|
||||
is ApiStandaloneFileInfo -> "/_download info $url"
|
||||
is ShowVersion -> "/version"
|
||||
}
|
||||
|
||||
@@ -2562,6 +2648,9 @@ sealed class CC {
|
||||
is ApiImportArchive -> "apiImportArchive"
|
||||
is ApiDeleteStorage -> "apiDeleteStorage"
|
||||
is ApiStorageEncryption -> "apiStorageEncryption"
|
||||
is TestStorageEncryption -> "testStorageEncryption"
|
||||
is ApiSaveSettings -> "apiSaveSettings"
|
||||
is ApiGetSettings -> "apiGetSettings"
|
||||
is ApiGetChats -> "apiGetChats"
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
|
||||
@@ -2654,6 +2743,9 @@ sealed class CC {
|
||||
is ListRemoteCtrls -> "listRemoteCtrls"
|
||||
is StopRemoteCtrl -> "stopRemoteCtrl"
|
||||
is DeleteRemoteCtrl -> "deleteRemoteCtrl"
|
||||
is ApiUploadStandaloneFile -> "apiUploadStandaloneFile"
|
||||
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
|
||||
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
|
||||
is ShowVersion -> "showVersion"
|
||||
}
|
||||
|
||||
@@ -2671,6 +2763,7 @@ sealed class CC {
|
||||
is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd))
|
||||
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
|
||||
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
|
||||
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
|
||||
else -> this
|
||||
}
|
||||
|
||||
@@ -3796,6 +3889,13 @@ val json = Json {
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
val jsonShort = Json {
|
||||
prettyPrint = false
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
val yaml = Yaml(configuration = YamlConfiguration(
|
||||
strictMode = false,
|
||||
encodeDefaults = false,
|
||||
@@ -3985,20 +4085,28 @@ sealed class CR {
|
||||
// receiving file events
|
||||
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("standaloneFileInfo") class StandaloneFileInfo(val fileMeta: MigrationFileLinkData?): CR()
|
||||
@Serializable @SerialName("rcvStandaloneFileCreated") class RcvStandaloneFileCreated(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() // send by chats
|
||||
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val receivedSize: Long, val totalSize: Long, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: UserRef, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvStandaloneFileComplete") class RcvStandaloneFileComplete(val user: UserRef, val targetPath: String, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR()
|
||||
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR()
|
||||
// sending file events
|
||||
@Serializable @SerialName("sndFileStart") class SndFileStart(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
|
||||
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem_: AChatItem?, val sndFileTransfer: SndFileTransfer): CR()
|
||||
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
|
||||
@Serializable @SerialName("sndStandaloneFileCreated") class SndStandaloneFileCreated(val user: UserRef, val fileTransferMeta: FileTransferMeta): CR() // returned by _upload
|
||||
@Serializable @SerialName("sndFileStartXFTP") class SndFileStartXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() // not used
|
||||
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
|
||||
@Serializable @SerialName("sndFileRedirectStartXFTP") class SndFileRedirectStartXFTP(val user: UserRef, val fileTransferMeta: FileTransferMeta, val redirectMeta: FileTransferMeta): CR()
|
||||
@Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR()
|
||||
@Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("sndStandaloneFileComplete") class SndStandaloneFileComplete(val user: UserRef, val fileTransferMeta: FileTransferMeta, val rcvURIs: List<String>): CR()
|
||||
@Serializable @SerialName("sndFileCancelledXFTP") class SndFileCancelledXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR()
|
||||
@Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR()
|
||||
// call events
|
||||
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
|
||||
@Serializable @SerialName("callInvitations") class CallInvitations(val callInvitations: List<RcvCallInvitation>): CR()
|
||||
@@ -4032,6 +4140,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
|
||||
@Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR()
|
||||
// general
|
||||
@Serializable class Response(val type: String, val json: String): CR()
|
||||
@Serializable class Invalid(val str: String): CR()
|
||||
@@ -4141,19 +4250,27 @@ sealed class CR {
|
||||
is NewMemberContactSentInv -> "newMemberContactSentInv"
|
||||
is NewMemberContactReceivedInv -> "newMemberContactReceivedInv"
|
||||
is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled"
|
||||
is StandaloneFileInfo -> "standaloneFileInfo"
|
||||
is RcvStandaloneFileCreated -> "rcvStandaloneFileCreated"
|
||||
is RcvFileAccepted -> "rcvFileAccepted"
|
||||
is RcvFileStart -> "rcvFileStart"
|
||||
is RcvFileComplete -> "rcvFileComplete"
|
||||
is RcvStandaloneFileComplete -> "rcvStandaloneFileComplete"
|
||||
is RcvFileCancelled -> "rcvFileCancelled"
|
||||
is SndStandaloneFileCreated -> "sndStandaloneFileCreated"
|
||||
is SndFileStartXFTP -> "sndFileStartXFTP"
|
||||
is RcvFileSndCancelled -> "rcvFileSndCancelled"
|
||||
is RcvFileProgressXFTP -> "rcvFileProgressXFTP"
|
||||
is SndFileRedirectStartXFTP -> "sndFileRedirectStartXFTP"
|
||||
is RcvFileError -> "rcvFileError"
|
||||
is SndFileCancelled -> "sndFileCancelled"
|
||||
is SndFileStart -> "sndFileStart"
|
||||
is SndFileComplete -> "sndFileComplete"
|
||||
is SndFileRcvCancelled -> "sndFileRcvCancelled"
|
||||
is SndFileStart -> "sndFileStart"
|
||||
is SndFileCancelled -> "sndFileCancelled"
|
||||
is SndFileProgressXFTP -> "sndFileProgressXFTP"
|
||||
is SndFileCompleteXFTP -> "sndFileCompleteXFTP"
|
||||
is SndStandaloneFileComplete -> "sndStandaloneFileComplete"
|
||||
is SndFileCancelledXFTP -> "sndFileCancelledXFTP"
|
||||
is SndFileError -> "sndFileError"
|
||||
is CallInvitations -> "callInvitations"
|
||||
is CallInvitation -> "callInvitation"
|
||||
@@ -4183,6 +4300,7 @@ sealed class CR {
|
||||
is ChatCmdError -> "chatCmdError"
|
||||
is ChatRespError -> "chatError"
|
||||
is ArchiveImported -> "archiveImported"
|
||||
is AppSettingsR -> "appSettings"
|
||||
is Response -> "* $type"
|
||||
is Invalid -> "* invalid json"
|
||||
}
|
||||
@@ -4195,7 +4313,7 @@ sealed class CR {
|
||||
is ChatStopped -> noDetails()
|
||||
is ApiChats -> withUser(user, json.encodeToString(chats))
|
||||
is ApiChat -> withUser(user, json.encodeToString(chat))
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(AChatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
|
||||
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
|
||||
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
|
||||
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
|
||||
@@ -4292,20 +4410,28 @@ sealed class CR {
|
||||
is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member")
|
||||
is NewMemberContactReceivedInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member")
|
||||
is RcvFileAcceptedSndCancelled -> withUser(user, noDetails())
|
||||
is StandaloneFileInfo -> json.encodeToString(fileMeta)
|
||||
is RcvStandaloneFileCreated -> noDetails()
|
||||
is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileStart -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileComplete -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem_))
|
||||
is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize")
|
||||
is RcvFileError -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileCancelled -> json.encodeToString(chatItem)
|
||||
is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize")
|
||||
is RcvStandaloneFileComplete -> withUser(user, targetPath)
|
||||
is RcvFileError -> withUser(user, json.encodeToString(chatItem_))
|
||||
is SndFileCancelled -> json.encodeToString(chatItem_)
|
||||
is SndStandaloneFileCreated -> noDetails()
|
||||
is SndFileStartXFTP -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileComplete -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem_))
|
||||
is SndFileStart -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize")
|
||||
is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nsentSize: $sentSize\ntotalSize: $totalSize")
|
||||
is SndFileRedirectStartXFTP -> withUser(user, json.encodeToString(redirectMeta))
|
||||
is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndFileError -> withUser(user, json.encodeToString(chatItem))
|
||||
is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString())
|
||||
is SndFileCancelledXFTP -> withUser(user, json.encodeToString(chatItem_))
|
||||
is SndFileError -> withUser(user, json.encodeToString(chatItem_))
|
||||
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}"
|
||||
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
|
||||
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
|
||||
@@ -4351,6 +4477,7 @@ sealed class CR {
|
||||
is ChatCmdError -> withUser(user_, chatError.string)
|
||||
is ChatRespError -> withUser(user_, chatError.string)
|
||||
is ArchiveImported -> "${archiveErrors.map { it.string } }"
|
||||
is AppSettingsR -> json.encodeToString(appSettings)
|
||||
is Response -> json
|
||||
is Invalid -> str
|
||||
}
|
||||
@@ -4764,6 +4891,7 @@ sealed class StoreError {
|
||||
is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId"
|
||||
is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP"
|
||||
is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP"
|
||||
is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP"
|
||||
is ConnectionNotFound -> "connectionNotFound"
|
||||
is ConnectionNotFoundById -> "connectionNotFoundById"
|
||||
is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId"
|
||||
@@ -4822,6 +4950,7 @@ sealed class StoreError {
|
||||
@Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError()
|
||||
@Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError()
|
||||
@Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError()
|
||||
@Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError()
|
||||
@Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError()
|
||||
@Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError()
|
||||
@@ -5167,3 +5296,205 @@ enum class NotificationsMode() {
|
||||
val default: NotificationsMode = SERVICE
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AppSettings(
|
||||
var networkConfig: NetCfg? = null,
|
||||
var privacyEncryptLocalFiles: Boolean? = null,
|
||||
var privacyAcceptImages: Boolean? = null,
|
||||
var privacyLinkPreviews: Boolean? = null,
|
||||
var privacyShowChatPreviews: Boolean? = null,
|
||||
var privacySaveLastDraft: Boolean? = null,
|
||||
var privacyProtectScreen: Boolean? = null,
|
||||
var notificationMode: AppSettingsNotificationMode? = null,
|
||||
var notificationPreviewMode: AppSettingsNotificationPreviewMode? = null,
|
||||
var webrtcPolicyRelay: Boolean? = null,
|
||||
var webrtcICEServers: List<String>? = null,
|
||||
var confirmRemoteSessions: Boolean? = null,
|
||||
var connectRemoteViaMulticast: Boolean? = null,
|
||||
var connectRemoteViaMulticastAuto: Boolean? = null,
|
||||
var developerTools: Boolean? = null,
|
||||
var confirmDBUpgrades: Boolean? = null,
|
||||
var androidCallOnLockScreen: AppSettingsLockScreenCalls? = null,
|
||||
var iosCallKitEnabled: Boolean? = null,
|
||||
var iosCallKitCallsInRecents: Boolean? = null,
|
||||
) {
|
||||
fun prepareForExport(): AppSettings {
|
||||
val empty = AppSettings()
|
||||
val def = defaults
|
||||
if (networkConfig != def.networkConfig) { empty.networkConfig = networkConfig }
|
||||
if (privacyEncryptLocalFiles != def.privacyEncryptLocalFiles) { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
|
||||
if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages }
|
||||
if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews }
|
||||
if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews }
|
||||
if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft }
|
||||
if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen }
|
||||
if (notificationMode != def.notificationMode) { empty.notificationMode = notificationMode }
|
||||
if (notificationPreviewMode != def.notificationPreviewMode) { empty.notificationPreviewMode = notificationPreviewMode }
|
||||
if (webrtcPolicyRelay != def.webrtcPolicyRelay) { empty.webrtcPolicyRelay = webrtcPolicyRelay }
|
||||
if (webrtcICEServers != def.webrtcICEServers) { empty.webrtcICEServers = webrtcICEServers }
|
||||
if (confirmRemoteSessions != def.confirmRemoteSessions) { empty.confirmRemoteSessions = confirmRemoteSessions }
|
||||
if (connectRemoteViaMulticast != def.connectRemoteViaMulticast) { empty.connectRemoteViaMulticast = connectRemoteViaMulticast }
|
||||
if (connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto) { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto }
|
||||
if (developerTools != def.developerTools) { empty.developerTools = developerTools }
|
||||
if (confirmDBUpgrades != def.confirmDBUpgrades) { empty.confirmDBUpgrades = confirmDBUpgrades }
|
||||
if (androidCallOnLockScreen != def.androidCallOnLockScreen) { empty.androidCallOnLockScreen = androidCallOnLockScreen }
|
||||
if (iosCallKitEnabled != def.iosCallKitEnabled) { empty.iosCallKitEnabled = iosCallKitEnabled }
|
||||
if (iosCallKitCallsInRecents != def.iosCallKitCallsInRecents) { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
|
||||
return empty
|
||||
}
|
||||
|
||||
fun importIntoApp() {
|
||||
val def = appPreferences
|
||||
var net = networkConfig?.copy()
|
||||
if (net != null) {
|
||||
// migrating from iOS BUT shouldn't be here ever because it should be changed on migration stage
|
||||
if (net.hostMode == HostMode.Onion) {
|
||||
net = net.copy(hostMode = HostMode.Public, requiredHostMode = true)
|
||||
}
|
||||
setNetCfg(net)
|
||||
}
|
||||
privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) }
|
||||
privacyAcceptImages?.let { def.privacyAcceptImages.set(it) }
|
||||
privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) }
|
||||
privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) }
|
||||
privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) }
|
||||
privacyProtectScreen?.let { def.privacyProtectScreen.set(it) }
|
||||
notificationMode?.let { def.notificationsMode.set(it.toNotificationsMode()) }
|
||||
notificationPreviewMode?.let { def.notificationPreviewMode.set(it.toNotificationPreviewMode().name) }
|
||||
webrtcPolicyRelay?.let { def.webrtcPolicyRelay.set(it) }
|
||||
webrtcICEServers?.let { def.webrtcIceServers.set(it.joinToString(separator = "\n")) }
|
||||
confirmRemoteSessions?.let { def.confirmRemoteSessions.set(it) }
|
||||
connectRemoteViaMulticast?.let { def.connectRemoteViaMulticast.set(it) }
|
||||
connectRemoteViaMulticastAuto?.let { def.connectRemoteViaMulticastAuto.set(it) }
|
||||
developerTools?.let { def.developerTools.set(it) }
|
||||
confirmDBUpgrades?.let { def.confirmDBUpgrades.set(it) }
|
||||
androidCallOnLockScreen?.let { def.callOnLockScreen.set(it.toCallOnLockScreen()) }
|
||||
iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) }
|
||||
iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaults: AppSettings
|
||||
get() = AppSettings(
|
||||
networkConfig = NetCfg.defaults,
|
||||
privacyEncryptLocalFiles = true,
|
||||
privacyAcceptImages = true,
|
||||
privacyLinkPreviews = true,
|
||||
privacyShowChatPreviews = true,
|
||||
privacySaveLastDraft = true,
|
||||
privacyProtectScreen = false,
|
||||
notificationMode = AppSettingsNotificationMode.INSTANT,
|
||||
notificationPreviewMode = AppSettingsNotificationPreviewMode.MESSAGE,
|
||||
webrtcPolicyRelay = true,
|
||||
webrtcICEServers = emptyList(),
|
||||
confirmRemoteSessions = false,
|
||||
connectRemoteViaMulticast = true,
|
||||
connectRemoteViaMulticastAuto = true,
|
||||
developerTools = false,
|
||||
confirmDBUpgrades = false,
|
||||
androidCallOnLockScreen = AppSettingsLockScreenCalls.SHOW,
|
||||
iosCallKitEnabled = true,
|
||||
iosCallKitCallsInRecents = false
|
||||
)
|
||||
|
||||
val current: AppSettings
|
||||
get() {
|
||||
val def = appPreferences
|
||||
return defaults.copy(
|
||||
networkConfig = getNetCfg(),
|
||||
privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(),
|
||||
privacyAcceptImages = def.privacyAcceptImages.get(),
|
||||
privacyLinkPreviews = def.privacyLinkPreviews.get(),
|
||||
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
|
||||
privacySaveLastDraft = def.privacySaveLastDraft.get(),
|
||||
privacyProtectScreen = def.privacyProtectScreen.get(),
|
||||
notificationMode = AppSettingsNotificationMode.from(def.notificationsMode.get()),
|
||||
notificationPreviewMode = AppSettingsNotificationPreviewMode.from(NotificationPreviewMode.valueOf(def.notificationPreviewMode.get()!!)),
|
||||
webrtcPolicyRelay = def.webrtcPolicyRelay.get(),
|
||||
webrtcICEServers = def.webrtcIceServers.get()?.lines(),
|
||||
confirmRemoteSessions = def.confirmRemoteSessions.get(),
|
||||
connectRemoteViaMulticast = def.connectRemoteViaMulticast.get(),
|
||||
connectRemoteViaMulticastAuto = def.connectRemoteViaMulticastAuto.get(),
|
||||
developerTools = def.developerTools.get(),
|
||||
confirmDBUpgrades = def.confirmDBUpgrades.get(),
|
||||
androidCallOnLockScreen = AppSettingsLockScreenCalls.from(def.callOnLockScreen.get()),
|
||||
iosCallKitEnabled = def.iosCallKitEnabled.get(),
|
||||
iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class AppSettingsNotificationMode {
|
||||
@SerialName("off") OFF,
|
||||
@SerialName("periodic") PERIODIC,
|
||||
@SerialName("instant") INSTANT;
|
||||
|
||||
fun toNotificationsMode(): NotificationsMode =
|
||||
when (this) {
|
||||
INSTANT -> NotificationsMode.SERVICE
|
||||
PERIODIC -> NotificationsMode.PERIODIC
|
||||
OFF -> NotificationsMode.OFF
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(mode: NotificationsMode): AppSettingsNotificationMode =
|
||||
when (mode) {
|
||||
NotificationsMode.SERVICE -> INSTANT
|
||||
NotificationsMode.PERIODIC -> PERIODIC
|
||||
NotificationsMode.OFF -> OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class AppSettingsNotificationPreviewMode {
|
||||
@SerialName("message") MESSAGE,
|
||||
@SerialName("contact") CONTACT,
|
||||
@SerialName("hidden") HIDDEN;
|
||||
|
||||
fun toNotificationPreviewMode(): NotificationPreviewMode =
|
||||
when (this) {
|
||||
MESSAGE -> NotificationPreviewMode.MESSAGE
|
||||
CONTACT -> NotificationPreviewMode.CONTACT
|
||||
HIDDEN -> NotificationPreviewMode.HIDDEN
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default: AppSettingsNotificationPreviewMode = MESSAGE
|
||||
|
||||
fun from(mode: NotificationPreviewMode): AppSettingsNotificationPreviewMode =
|
||||
when (mode) {
|
||||
NotificationPreviewMode.MESSAGE -> MESSAGE
|
||||
NotificationPreviewMode.CONTACT -> CONTACT
|
||||
NotificationPreviewMode.HIDDEN -> HIDDEN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class AppSettingsLockScreenCalls {
|
||||
@SerialName("disable") DISABLE,
|
||||
@SerialName("show") SHOW,
|
||||
@SerialName("accept") ACCEPT;
|
||||
|
||||
fun toCallOnLockScreen(): CallOnLockScreen =
|
||||
when (this) {
|
||||
DISABLE -> CallOnLockScreen.DISABLE
|
||||
SHOW -> CallOnLockScreen.SHOW
|
||||
ACCEPT -> CallOnLockScreen.ACCEPT
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default = SHOW
|
||||
|
||||
fun from(mode: CallOnLockScreen): AppSettingsLockScreenCalls =
|
||||
when (mode) {
|
||||
CallOnLockScreen.DISABLE -> DISABLE
|
||||
CallOnLockScreen.SHOW -> SHOW
|
||||
CallOnLockScreen.ACCEPT -> ACCEPT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.currentUser
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
|
||||
import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
// ghc's rts
|
||||
@@ -137,6 +139,33 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
|
||||
}
|
||||
}
|
||||
|
||||
fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair<DBMigrationResult, ChatCtrl?> {
|
||||
val dbKey = key ?: randomDatabasePassword()
|
||||
Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath")
|
||||
val migrated = chatMigrateInit(dbPath, dbKey, confirmation.value)
|
||||
val res = runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
|
||||
return res to migrated[1] as ChatCtrl
|
||||
}
|
||||
|
||||
fun chatInitControllerRemovingDatabases() {
|
||||
val dbPath = dbAbsolutePrefixPath
|
||||
val dbKey = randomDatabasePassword()
|
||||
Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath")
|
||||
val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value)
|
||||
val res = runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
|
||||
val ctrl = migrated[1] as Long
|
||||
chatController.ctrl = ctrl
|
||||
// We need only controller, not databases
|
||||
File(dbPath + "_chat.db").delete()
|
||||
File(dbPath + "_agent.db").delete()
|
||||
}
|
||||
|
||||
fun showStartChatAfterRestartAlert(): CompletableDeferred<Boolean> {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
AlertManager.shared.showAlertDialog(
|
||||
|
||||
@@ -66,6 +66,8 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMigrationTempFilesDirectory(): File = File(dataDir, "migration_temp_files")
|
||||
|
||||
fun getAppFilePath(fileName: String): String {
|
||||
val rh = chatModel.currentRemoteHost.value
|
||||
val s = File.separator
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.model.ChatId
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
|
||||
@@ -16,6 +17,7 @@ interface PlatformInterface {
|
||||
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
|
||||
fun androidPictureInPictureAllowed(): Boolean = true
|
||||
fun androidCallEnded() {}
|
||||
@Composable fun androidLockPortraitOrientation() {}
|
||||
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
|
||||
}
|
||||
/**
|
||||
|
||||
+28
-5
@@ -379,6 +379,30 @@ fun ChatItemView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun E2EEInfoNoPQText() {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) }
|
||||
},
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) {
|
||||
if (e2EEInfo.pqEnabled) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) }
|
||||
},
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp)
|
||||
)
|
||||
} else {
|
||||
E2EEInfoNoPQText()
|
||||
}
|
||||
}
|
||||
|
||||
when (val c = cItem.content) {
|
||||
is CIContent.SndMsgContent -> ContentItem()
|
||||
is CIContent.RcvMsgContent -> ContentItem()
|
||||
@@ -452,11 +476,10 @@ fun ChatItemView(
|
||||
is CIContent.SndModerated -> DeletedItem()
|
||||
is CIContent.RcvModerated -> DeletedItem()
|
||||
is CIContent.RcvBlocked -> DeletedItem()
|
||||
// TODO proper items
|
||||
is CIContent.SndDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) })
|
||||
is CIContent.RcvDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) })
|
||||
is CIContent.SndGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) })
|
||||
is CIContent.RcvGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) })
|
||||
is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo)
|
||||
is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo)
|
||||
is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText()
|
||||
is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText()
|
||||
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-24
@@ -1,9 +1,8 @@
|
||||
package chat.simplex.common.views.database
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionTextFooter
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -24,20 +23,22 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.appPreferences
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
|
||||
@Composable
|
||||
fun DatabaseEncryptionView(m: ChatModel) {
|
||||
fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
|
||||
val progressIndicator = remember { mutableStateOf(false) }
|
||||
val prefs = m.controller.appPrefs
|
||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||
@@ -61,9 +62,10 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
||||
storedKey,
|
||||
initialRandomDBPassphrase,
|
||||
progressIndicator,
|
||||
migration,
|
||||
onConfirmEncrypt = {
|
||||
withLongRunningApi {
|
||||
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
|
||||
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator, migration)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -95,24 +97,34 @@ fun DatabaseEncryptionLayout(
|
||||
storedKey: MutableState<Boolean>,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
onConfirmEncrypt: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
if (!migration) Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) else Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.database_passphrase))
|
||||
SectionView(null) {
|
||||
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
|
||||
if (!migration) {
|
||||
AppBarTitle(stringResource(MR.strings.database_passphrase))
|
||||
} else {
|
||||
ChatStoppedView()
|
||||
SectionSpacer()
|
||||
}
|
||||
SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) {
|
||||
SavePassphraseSetting(
|
||||
useKeychain.value,
|
||||
initialRandomDBPassphrase.value,
|
||||
storedKey.value,
|
||||
enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration
|
||||
) { checked ->
|
||||
if (checked) {
|
||||
setUseKeychain(true, useKeychain, prefs)
|
||||
} else if (storedKey.value) {
|
||||
setUseKeychain(true, useKeychain, prefs, migration)
|
||||
} else if (storedKey.value && !migration) {
|
||||
// Don't show in migration process since it will remove the key after successful encryption
|
||||
removePassphraseAlert {
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
storedKey.value = false
|
||||
removePassphraseFromKeyChain(useKeychain, prefs, storedKey, false)
|
||||
}
|
||||
} else {
|
||||
setUseKeychain(false, useKeychain, prefs)
|
||||
setUseKeychain(false, useKeychain, prefs, migration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,12 +181,12 @@ fun DatabaseEncryptionLayout(
|
||||
)
|
||||
|
||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||
Text(generalGetString(MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase)
|
||||
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
@@ -211,8 +223,9 @@ expect fun SavePassphraseSetting(
|
||||
useKeychain: Boolean,
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||
enabled: Boolean,
|
||||
smallPadding: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
|
||||
@@ -222,8 +235,18 @@ expect fun DatabaseEncryptionFooter(
|
||||
chatDbEncrypted: Boolean?,
|
||||
storedKey: MutableState<Boolean>,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChatStoppedView() {
|
||||
SettingsActionItem(
|
||||
icon = painterResource(MR.images.ic_report_filled),
|
||||
text = stringResource(MR.strings.chat_is_stopped),
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
fun resetFormAfterEncryption(
|
||||
m: ChatModel,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
@@ -242,9 +265,18 @@ fun resetFormAfterEncryption(
|
||||
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
|
||||
}
|
||||
|
||||
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
|
||||
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences, migration: Boolean) {
|
||||
useKeychain.value = value
|
||||
prefs.storeDBPassphrase.set(value)
|
||||
// Postpone it when migrating to the end of encryption process
|
||||
if (!migration) {
|
||||
prefs.storeDBPassphrase.set(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removePassphraseFromKeyChain(useKeychain: MutableState<Boolean>, prefs: AppPreferences, storedKey: MutableState<Boolean>, migration: Boolean) {
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
setUseKeychain(false, useKeychain, prefs, migration)
|
||||
storedKey.value = false
|
||||
}
|
||||
|
||||
fun storeSecurelySaved() = generalGetString(MR.strings.store_passphrase_securely)
|
||||
@@ -267,6 +299,7 @@ fun PassphraseField(
|
||||
isValid: (String) -> Boolean,
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
dependsOn: State<Any?>? = null,
|
||||
requestFocus: Boolean = false,
|
||||
) {
|
||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
@@ -295,6 +328,7 @@ fun PassphraseField(
|
||||
val color = MaterialTheme.colors.onBackground
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
@@ -304,7 +338,8 @@ fun PassphraseField(
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
)
|
||||
.focusRequester(focusRequester),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
key.value = it.text
|
||||
@@ -347,6 +382,12 @@ fun PassphraseField(
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
if (requestFocus) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { dependsOn?.value }
|
||||
.distinctUntilChanged()
|
||||
@@ -363,13 +404,17 @@ suspend fun encryptDatabase(
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
useKeychain: MutableState<Boolean>,
|
||||
storedKey: MutableState<Boolean>,
|
||||
progressIndicator: MutableState<Boolean>
|
||||
progressIndicator: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
): Boolean {
|
||||
val m = ChatModel
|
||||
val prefs = ChatController.appPrefs
|
||||
progressIndicator.value = true
|
||||
return try {
|
||||
prefs.encryptionStartedAt.set(Clock.System.now())
|
||||
if (!m.chatDbChanged.value) {
|
||||
m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
|
||||
}
|
||||
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
|
||||
prefs.encryptionStartedAt.set(null)
|
||||
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
|
||||
@@ -393,9 +438,14 @@ suspend fun encryptDatabase(
|
||||
}
|
||||
else -> {
|
||||
val new = newKey.value
|
||||
if (migration) {
|
||||
appPreferences.storeDBPassphrase.set(useKeychain.value)
|
||||
}
|
||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||
if (useKeychain.value) {
|
||||
DatabaseUtils.ksDatabasePassword.set(new)
|
||||
} else if (migration) {
|
||||
removePassphraseFromKeyChain(useKeychain, prefs, storedKey, true)
|
||||
}
|
||||
operationEnded(m, progressIndicator) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
|
||||
@@ -474,6 +524,7 @@ fun PreviewDatabaseEncryptionLayout() {
|
||||
storedKey = remember { mutableStateOf(true) },
|
||||
initialRandomDBPassphrase = remember { mutableStateOf(true) },
|
||||
progressIndicator = remember { mutableStateOf(false) },
|
||||
migration = false,
|
||||
onConfirmEncrypt = {},
|
||||
)
|
||||
}
|
||||
|
||||
+9
-3
@@ -206,6 +206,14 @@ private fun runChat(
|
||||
is DBMigrationResult.OK -> {
|
||||
platform.androidChatStartedAfterBeingOff()
|
||||
}
|
||||
null -> {}
|
||||
else -> showErrorOnMigrationIfNeeded(status)
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorOnMigrationIfNeeded(status: DBMigrationResult) =
|
||||
when (status) {
|
||||
is DBMigrationResult.OK -> {}
|
||||
is DBMigrationResult.ErrorNotADatabase ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase))
|
||||
is DBMigrationResult.ErrorSQL ->
|
||||
@@ -217,9 +225,7 @@ private fun runChat(
|
||||
is DBMigrationResult.InvalidConfirmation ->
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.invalid_migration_confirmation))
|
||||
is DBMigrationResult.ErrorMigration -> {}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean {
|
||||
val startedAt = prefs.encryptionStartedAt.get() ?: return false
|
||||
@@ -246,7 +252,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
|
||||
}
|
||||
}
|
||||
|
||||
private fun mtrErrorDescription(err: MTRError): String =
|
||||
fun mtrErrorDescription(err: MTRError): String =
|
||||
when (err) {
|
||||
is MTRError.NoDown ->
|
||||
String.format(generalGetString(MR.strings.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||
|
||||
+15
-7
@@ -211,7 +211,7 @@ fun DatabaseLayout(
|
||||
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
|
||||
else painterResource(MR.images.ic_lock),
|
||||
stringResource(MR.strings.database_passphrase),
|
||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||
click = showSettingsModal() { DatabaseEncryptionView(it, false) },
|
||||
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
|
||||
disabled = operationsDisabled
|
||||
)
|
||||
@@ -486,6 +486,7 @@ fun deleteChatDatabaseFilesAndState() {
|
||||
filesDir.mkdir()
|
||||
remoteHostsDir.deleteRecursively()
|
||||
tmpDir.deleteRecursively()
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
tmpDir.mkdir()
|
||||
DatabaseUtils.ksDatabasePassword.remove()
|
||||
controller.appPrefs.storeDBPassphrase.set(true)
|
||||
@@ -509,7 +510,7 @@ private fun exportArchive(
|
||||
progressIndicator.value = true
|
||||
withLongRunningApi {
|
||||
try {
|
||||
val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile)
|
||||
val archiveFile = exportChatArchive(m, null, chatArchiveName, chatArchiveTime, chatArchiveFile)
|
||||
chatArchiveFile.value = archiveFile
|
||||
saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator))
|
||||
progressIndicator.value = false
|
||||
@@ -520,8 +521,9 @@ private fun exportArchive(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exportChatArchive(
|
||||
suspend fun exportChatArchive(
|
||||
m: ChatModel,
|
||||
storagePath: File?,
|
||||
chatArchiveName: MutableState<String?>,
|
||||
chatArchiveTime: MutableState<Instant?>,
|
||||
chatArchiveFile: MutableState<String?>
|
||||
@@ -529,13 +531,19 @@ private suspend fun exportChatArchive(
|
||||
val archiveTime = Clock.System.now()
|
||||
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
val archiveName = "simplex-chat.$ts.zip"
|
||||
val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName"
|
||||
val archivePath = "${(storagePath ?: filesDir).absolutePath}${File.separator}$archiveName"
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
|
||||
// Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first
|
||||
if (!m.chatDbChanged.value) {
|
||||
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
|
||||
}
|
||||
m.controller.apiExportArchive(config)
|
||||
deleteOldArchive(m)
|
||||
m.controller.appPrefs.chatArchiveName.set(archiveName)
|
||||
if (storagePath == null) {
|
||||
deleteOldArchive(m)
|
||||
m.controller.appPrefs.chatArchiveName.set(archiveName)
|
||||
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
|
||||
}
|
||||
chatArchiveName.value = archiveName
|
||||
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
|
||||
chatArchiveTime.value = archiveTime
|
||||
chatArchiveFile.value = archivePath
|
||||
return archivePath
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ object DatabaseUtils {
|
||||
return dbKey
|
||||
}
|
||||
|
||||
private fun randomDatabasePassword(): String {
|
||||
fun randomDatabasePassword(): String {
|
||||
val s = ByteArray(32)
|
||||
SecureRandom().nextBytes(s)
|
||||
return s.toBase64StringForPassphrase().replace("\n", "")
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ fun DefaultProgressView(description: String?) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
.padding(bottom = if (description != null) DEFAULT_PADDING else 0.dp)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
|
||||
+3
-2
@@ -19,17 +19,18 @@ import kotlin.math.min
|
||||
fun ModalView(
|
||||
close: () -> Unit,
|
||||
showClose: Boolean = true,
|
||||
enableClose: Boolean = true,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
modifier: Modifier = Modifier,
|
||||
endButtons: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (showClose) {
|
||||
BackHandler(onBack = close)
|
||||
BackHandler(enabled = enableClose, onBack = close)
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
|
||||
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
|
||||
CloseSheetBar(close, showClose, endButtons = endButtons)
|
||||
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
|
||||
Box(modifier) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
+721
@@ -0,0 +1,721 @@
|
||||
package chat.simplex.common.views.migration
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE
|
||||
import chat.simplex.common.model.ChatController.getNetCfg
|
||||
import chat.simplex.common.model.ChatController.startChat
|
||||
import chat.simplex.common.model.ChatCtrl
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.database.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
|
||||
import chat.simplex.common.views.newchat.QRCodeScanner
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.toJavaInstant
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
|
||||
@Serializable
|
||||
sealed class MigrationFromAnotherDeviceState {
|
||||
@Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState()
|
||||
@Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState()
|
||||
@Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState()
|
||||
@Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState()
|
||||
|
||||
companion object {
|
||||
// Here we check whether it's needed to show migration process after app restart or not
|
||||
// It's important to NOT show the process when archive was corrupted/not fully downloaded
|
||||
fun transform(): MigrationFromAnotherDeviceState? {
|
||||
val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE)
|
||||
var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null
|
||||
if (state is DownloadProgress) {
|
||||
// No migration happens at the moment actually since archive were not downloaded fully
|
||||
Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file")
|
||||
state = null
|
||||
} else if (state is Onion) {
|
||||
state = null
|
||||
} else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) {
|
||||
Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration")
|
||||
state = null
|
||||
}
|
||||
if (state == null) {
|
||||
settings.remove(SHARED_PREFS_MIGRATION_STAGE)
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
fun save(state: MigrationFromAnotherDeviceState?) {
|
||||
if (state != null) {
|
||||
appPreferences.migrationStage.set(json.encodeToString(state))
|
||||
} else {
|
||||
appPreferences.migrationStage.set(null)
|
||||
}
|
||||
chatModel.migrationState.value = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private sealed class MigrationState {
|
||||
@Serializable object PasteOrScanLink: MigrationState()
|
||||
@Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState()
|
||||
@Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState()
|
||||
@Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState()
|
||||
@Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState()
|
||||
}
|
||||
|
||||
private var MutableState<MigrationState>.state: MigrationState
|
||||
get() = value
|
||||
set(v) { value = v }
|
||||
|
||||
@Composable
|
||||
fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) {
|
||||
val migrationState = rememberSaveable(stateSaver = serializableSaver()) {
|
||||
mutableStateOf(
|
||||
when (state) {
|
||||
null -> MigrationState.PasteOrScanLink
|
||||
is MigrationFromAnotherDeviceState.Onion -> {
|
||||
MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode)
|
||||
}
|
||||
is MigrationFromAnotherDeviceState.DownloadProgress -> {
|
||||
val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName)
|
||||
// SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case.
|
||||
// See analyzeMigrationState()
|
||||
MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg)
|
||||
}
|
||||
is MigrationFromAnotherDeviceState.ArchiveImport -> {
|
||||
val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName)
|
||||
MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg)
|
||||
}
|
||||
is MigrationFromAnotherDeviceState.Passphrase -> {
|
||||
MigrationState.Passphrase("", state.netCfg)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Prevent from hiding the view until migration is finished or app deleted
|
||||
val backDisabled = remember {
|
||||
derivedStateOf {
|
||||
val s = chatModel.migrationState.value
|
||||
s is MigrationFromAnotherDeviceState.ArchiveImport ||
|
||||
s is MigrationFromAnotherDeviceState.Passphrase ||
|
||||
migrationState.value is MigrationState.DatabaseInit
|
||||
}
|
||||
}
|
||||
val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) }
|
||||
ModalView(
|
||||
enableClose = !backDisabled.value,
|
||||
close = {
|
||||
withBGApi {
|
||||
migrationState.cleanUpOnBack(chatReceiver.value)
|
||||
close()
|
||||
}
|
||||
},
|
||||
) {
|
||||
MigrateFromAnotherDeviceLayout(
|
||||
migrationState = migrationState,
|
||||
chatReceiver = chatReceiver,
|
||||
close = close,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModalData.MigrateFromAnotherDeviceLayout(
|
||||
migrationState: MutableState<MigrationState>,
|
||||
chatReceiver: MutableState<MigrationFromChatReceiver?>,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.migrate_here))
|
||||
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
platform.androidLockPortraitOrientation()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModalData.SectionByState(
|
||||
migrationState: MutableState<MigrationState>,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationFromChatReceiver?>,
|
||||
close: () -> Unit
|
||||
) {
|
||||
when (val s = migrationState.value) {
|
||||
is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView()
|
||||
is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState)
|
||||
is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg)
|
||||
is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg)
|
||||
is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes)
|
||||
is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg)
|
||||
is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg)
|
||||
is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg)
|
||||
is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg)
|
||||
is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg)
|
||||
is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.PasteOrScanLinkView() {
|
||||
if (appPlatform.isAndroid) {
|
||||
SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) {
|
||||
QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text ->
|
||||
withBGApi { checkUserLink(text) }
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
if (appPlatform.isDesktop || appPreferences.developerTools.get()) {
|
||||
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
|
||||
PasteLinkView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.PasteLinkView() {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SectionItemView({
|
||||
val str = clipboard.getText()?.text ?: return@SectionItemView
|
||||
withBGApi { checkUserLink(str) }
|
||||
}) {
|
||||
Text(stringResource(MR.strings.tap_to_paste_link))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState<MigrationState>) {
|
||||
val onionHosts = remember { stateGetOrPut("onionHosts") {
|
||||
getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts
|
||||
} }
|
||||
val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { socksProxy != null } }
|
||||
val sessionMode = remember { stateGetOrPut("sessionMode") { TransportSessionMode.User} }
|
||||
val networkProxyHostPort = remember { stateGetOrPut("networkHostProxyPort") {
|
||||
var proxy = (socksProxy ?: chatModel.controller.appPrefs.networkProxyHostPort.get())
|
||||
if (proxy?.startsWith(":") == true) proxy = "localhost$proxy"
|
||||
proxy
|
||||
}
|
||||
}
|
||||
val proxyPort = remember { derivedStateOf { networkProxyHostPort.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }
|
||||
|
||||
val netCfg = rememberSaveable(stateSaver = serializableSaver()) {
|
||||
mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value))
|
||||
}
|
||||
|
||||
SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_check),
|
||||
text = stringResource(MR.strings.migration_from_device_apply_onion),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
val updated = netCfg.value
|
||||
.withOnionHosts(onionHosts.value)
|
||||
.withHostPort(if (networkUseSocksProxy.value) networkProxyHostPort.value else null, null)
|
||||
.copy(
|
||||
sessionMode = sessionMode.value
|
||||
)
|
||||
withBGApi {
|
||||
state.value = MigrationState.DatabaseInit(link, updated)
|
||||
}
|
||||
}
|
||||
){}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer))
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
val networkProxyHostPortPref = SharedPreference(get = { networkProxyHostPort.value }, set = {
|
||||
networkProxyHostPort.value = it
|
||||
})
|
||||
SectionView(stringResource(MR.strings.network_settings_title).uppercase()) {
|
||||
OnionRelatedLayout(
|
||||
appPreferences.developerTools.get(),
|
||||
networkUseSocksProxy,
|
||||
onionHosts,
|
||||
sessionMode,
|
||||
networkProxyHostPortPref,
|
||||
proxyPort,
|
||||
toggleSocksProxy = { enable ->
|
||||
networkUseSocksProxy.value = enable
|
||||
},
|
||||
useOnion = {
|
||||
onionHosts.value = it
|
||||
},
|
||||
updateSessionMode = {
|
||||
sessionMode.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
prepareDatabase(link, tempDatabaseFile, netCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.LinkDownloadingView(
|
||||
link: String,
|
||||
ctrl: ChatCtrl,
|
||||
user: User,
|
||||
archivePath: String,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationFromChatReceiver?>,
|
||||
netCfg: NetCfg
|
||||
) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) {
|
||||
val ratio = downloadedBytes.toFloat() / max(totalBytes, 1)
|
||||
LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_download),
|
||||
text = stringResource(MR.strings.migration_from_device_repeat_download),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
state = MigrationState.DatabaseInit(link, netCfg)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
File(archivePath).delete()
|
||||
MigrationFromAnotherDeviceState.save(null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.ArchiveImportView(archivePath: String, netCfg: NetCfg) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
importArchive(archivePath, netCfg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_download),
|
||||
text = stringResource(MR.strings.migration_from_device_repeat_import),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
state = MigrationState.ArchiveImport(archivePath, netCfg)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) {
|
||||
val currentKey = rememberSaveable { mutableStateOf(currentKey) }
|
||||
val verifyingPassphrase = rememberSaveable { mutableStateOf(false) }
|
||||
val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||
|
||||
Box {
|
||||
val view = LocalMultiplatformView()
|
||||
SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) {
|
||||
SavePassphraseSetting(
|
||||
useKeychain.value,
|
||||
false,
|
||||
false,
|
||||
enabled = !verifyingPassphrase.value,
|
||||
smallPadding = false
|
||||
) { checked -> useKeychain.value = checked }
|
||||
|
||||
PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true)
|
||||
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_vpn_key_filled),
|
||||
text = stringResource(MR.strings.open_chat),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
disabled = verifyingPassphrase.value || currentKey.value.isEmpty(),
|
||||
click = {
|
||||
verifyingPassphrase.value = true
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp)
|
||||
val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation
|
||||
if (success) {
|
||||
state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg)
|
||||
} else if (status is DBMigrationResult.ErrorMigration) {
|
||||
state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg)
|
||||
} else {
|
||||
showErrorOnMigrationIfNeeded(status)
|
||||
}
|
||||
verifyingPassphrase.value = false
|
||||
}
|
||||
}
|
||||
) {}
|
||||
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true)
|
||||
}
|
||||
if (verifyingPassphrase.value) {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationState>.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) {
|
||||
data class Tuple4<A,B,C,D>(val a: A, val b: B, val c: C, val d: D)
|
||||
val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) {
|
||||
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||
is MigrationError.Upgrade ->
|
||||
Tuple4(
|
||||
generalGetString(MR.strings.database_upgrade),
|
||||
generalGetString(MR.strings.upgrade_and_open_chat),
|
||||
"",
|
||||
MigrationConfirmation.YesUp
|
||||
)
|
||||
is MigrationError.Downgrade ->
|
||||
Tuple4(
|
||||
generalGetString(MR.strings.database_downgrade),
|
||||
generalGetString(MR.strings.downgrade_and_open_chat),
|
||||
generalGetString(MR.strings.database_downgrade_warning),
|
||||
MigrationConfirmation.YesUpDown
|
||||
)
|
||||
is MigrationError.Error ->
|
||||
Tuple4(
|
||||
generalGetString(MR.strings.incompatible_database_version),
|
||||
null,
|
||||
mtrErrorDescription(err.mtrError),
|
||||
null
|
||||
)
|
||||
}
|
||||
else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null)
|
||||
}
|
||||
SectionView(header.uppercase()) {
|
||||
if (button != null && confirmation != null) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_download),
|
||||
text = button,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg)
|
||||
}
|
||||
) {}
|
||||
}
|
||||
SectionTextFooter(footer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
startChat(passphrase, confirmation, useKeychain, netCfg, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressView() {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
|
||||
private suspend fun MutableState<MigrationState>.checkUserLink(link: String) {
|
||||
if (strHasSimplexFileLink(link.trim())) {
|
||||
val data = MigrationFileLinkData.readFromLink(link)
|
||||
val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false
|
||||
val networkConfig = data?.networkConfig?.transformToPlatformSupported()
|
||||
// If any of iOS or Android had onion enabled, show onion screen
|
||||
if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) {
|
||||
state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)
|
||||
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode))
|
||||
} else {
|
||||
val current = getNetCfg()
|
||||
state = MigrationState.DatabaseInit(link.trim(), current.copy(
|
||||
socksProxy = networkConfig?.socksProxy,
|
||||
hostMode = networkConfig?.hostMode ?: current.hostMode,
|
||||
requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode
|
||||
))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.invalid_file_link),
|
||||
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationState>.prepareDatabase(
|
||||
link: String,
|
||||
tempDatabaseFile: File,
|
||||
netCfg: NetCfg,
|
||||
) {
|
||||
withLongRunningApi {
|
||||
val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg)
|
||||
if (ctrlAndUser == null) {
|
||||
state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg)
|
||||
return@withLongRunningApi
|
||||
}
|
||||
|
||||
val (ctrl, user) = ctrlAndUser
|
||||
state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationState>.startDownloading(
|
||||
totalBytes: Long,
|
||||
ctrl: ChatCtrl,
|
||||
user: User,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationFromChatReceiver?>,
|
||||
link: String,
|
||||
archivePath: String,
|
||||
netCfg: NetCfg,
|
||||
) {
|
||||
withBGApi {
|
||||
chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg ->
|
||||
when (msg) {
|
||||
is CR.RcvFileProgressXFTP -> {
|
||||
state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl)
|
||||
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg))
|
||||
}
|
||||
is CR.RcvStandaloneFileComplete -> {
|
||||
delay(500)
|
||||
state = MigrationState.ArchiveImport(archivePath, netCfg)
|
||||
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg))
|
||||
}
|
||||
is CR.RcvFileError -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.migration_from_device_download_failed),
|
||||
generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid)
|
||||
)
|
||||
state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg)
|
||||
}
|
||||
else -> Log.d(TAG, "unsupported event: ${msg.responseType}")
|
||||
}
|
||||
}
|
||||
chatReceiver.value?.start()
|
||||
|
||||
val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl)
|
||||
if (res == null) {
|
||||
state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.migration_from_device_error_downloading_archive),
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationState>.importArchive(archivePath: String, netCfg: NetCfg) {
|
||||
withLongRunningApi {
|
||||
try {
|
||||
if (ChatController.ctrl == null || ChatController.ctrl == -1L) {
|
||||
chatInitControllerRemovingDatabases()
|
||||
}
|
||||
controller.apiDeleteStorage()
|
||||
try {
|
||||
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
|
||||
val archiveErrors = controller.apiImportArchive(config)
|
||||
if (archiveErrors.isNotEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.chat_database_imported),
|
||||
generalGetString(MR.strings.non_fatal_errors_occured_during_import)
|
||||
)
|
||||
}
|
||||
state = MigrationState.Passphrase("", netCfg)
|
||||
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg))
|
||||
} catch (e: Exception) {
|
||||
state = MigrationState.ArchiveImportFailed(archivePath, netCfg)
|
||||
AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = MigrationState.ArchiveImportFailed(archivePath, netCfg)
|
||||
AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stopArchiveDownloading(fileId: Long, ctrl: ChatCtrl) {
|
||||
controller.apiCancelFile(null, fileId, ctrl)
|
||||
}
|
||||
|
||||
private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) {
|
||||
if (useKeychain) {
|
||||
ksDatabasePassword.set(passphrase)
|
||||
} else {
|
||||
ksDatabasePassword.remove()
|
||||
}
|
||||
appPreferences.storeDBPassphrase.set(useKeychain)
|
||||
appPreferences.initialRandomDBPassphrase.set(false)
|
||||
withBGApi {
|
||||
try {
|
||||
initChatController(useKey = passphrase, confirmMigrations = confirmation) { CompletableDeferred(false) }
|
||||
val appSettings = controller.apiGetAppSettings(AppSettings.current.prepareForExport()).copy(
|
||||
networkConfig = netCfg
|
||||
)
|
||||
finishMigration(appSettings, close)
|
||||
} catch (e: Exception) {
|
||||
hideView(close)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) {
|
||||
try {
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
appSettings.importIntoApp()
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
startChat(user)
|
||||
}
|
||||
hideView(close)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration))
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString())
|
||||
}
|
||||
MigrationFromAnotherDeviceState.save(null)
|
||||
}
|
||||
|
||||
private fun hideView(close: () -> Unit) {
|
||||
appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete)
|
||||
close()
|
||||
}
|
||||
|
||||
private suspend fun MutableState<MigrationState>.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) {
|
||||
val state = state
|
||||
if (state is MigrationState.ArchiveImportFailed) {
|
||||
// Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state
|
||||
deleteChatDatabaseFilesAndState()
|
||||
initChatControllerAndRunMigrations()
|
||||
} else if (state is MigrationState.DownloadProgress && state.ctrl != null) {
|
||||
stopArchiveDownloading(state.fileId, state.ctrl)
|
||||
}
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
MigrationFromAnotherDeviceState.save(null)
|
||||
}
|
||||
|
||||
private fun strHasSimplexFileLink(text: String): Boolean =
|
||||
text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file")
|
||||
|
||||
private fun fileForTemporaryDatabase(): File =
|
||||
File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory()))
|
||||
|
||||
private fun archivePath(): String {
|
||||
val archiveTime = Clock.System.now()
|
||||
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
|
||||
val archiveName = "simplex-chat.$ts.zip"
|
||||
val archivePath = File(getMigrationTempFilesDirectory(), archiveName)
|
||||
return archivePath.absolutePath
|
||||
}
|
||||
|
||||
private class MigrationFromChatReceiver(
|
||||
val ctrl: ChatCtrl,
|
||||
val databaseUrl: File,
|
||||
var receiveMessages: Boolean = true,
|
||||
val processReceivedMsg: suspend (CR) -> Unit
|
||||
) {
|
||||
fun start() {
|
||||
Log.d(TAG, "MigrationChatReceiver startReceiver")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (receiveMessages) {
|
||||
try {
|
||||
val msg = ChatController.recvMsg(ctrl)
|
||||
if (msg != null && receiveMessages) {
|
||||
val r = msg.resp
|
||||
val rhId = msg.remoteHostId
|
||||
Log.d(TAG, "processReceivedMsg: ${r.responseType}")
|
||||
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
|
||||
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
|
||||
processReceivedMsg(r)
|
||||
}
|
||||
if (finishedWithoutTimeout == null) {
|
||||
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
|
||||
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.possible_slow_function_title),
|
||||
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
|
||||
shareText = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAndCleanUp() {
|
||||
Log.d(TAG, "MigrationChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
chatCloseStore(ctrl)
|
||||
File(databaseUrl.absolutePath + "_chat.db").delete()
|
||||
File(databaseUrl.absolutePath + "_agent.db").delete()
|
||||
}
|
||||
}
|
||||
+683
@@ -0,0 +1,683 @@
|
||||
package chat.simplex.common.views.migration
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.getNetCfg
|
||||
import chat.simplex.common.model.ChatController.startChat
|
||||
import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase
|
||||
import chat.simplex.common.model.ChatCtrl
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.database.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.LinkTextView
|
||||
import chat.simplex.common.views.newchat.SimpleXLinkQRCode
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.*
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.max
|
||||
|
||||
@Serializable
|
||||
data class MigrationFileLinkData(
|
||||
val networkConfig: NetworkConfig?,
|
||||
) {
|
||||
@Serializable
|
||||
data class NetworkConfig(
|
||||
val socksProxy: String?,
|
||||
val hostMode: HostMode?,
|
||||
val requiredHostMode: Boolean?
|
||||
) {
|
||||
fun hasOnionConfigured(): Boolean = socksProxy != null || hostMode == HostMode.Onion
|
||||
|
||||
fun transformToPlatformSupported(): NetworkConfig {
|
||||
return if (hostMode != null && requiredHostMode != null) {
|
||||
NetworkConfig(
|
||||
socksProxy = if (hostMode == HostMode.Onion) socksProxy ?: NetCfg.proxyDefaults.socksProxy else socksProxy,
|
||||
hostMode = if (hostMode == HostMode.Onion) HostMode.OnionViaSocks else hostMode,
|
||||
requiredHostMode = requiredHostMode
|
||||
)
|
||||
} else this
|
||||
}
|
||||
}
|
||||
|
||||
fun addToLink(link: String) = link + "&data=" + URLEncoder.encode(jsonShort.encodeToString(this), "UTF-8")
|
||||
|
||||
companion object {
|
||||
suspend fun readFromLink(link: String): MigrationFileLinkData? =
|
||||
try {
|
||||
// val data = link.substringAfter("&data=").substringBefore("&")
|
||||
// json.decodeFromString(URLDecoder.decode(data, "UTF-8"))
|
||||
controller.standaloneFileInfo(link)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Serializable
|
||||
private sealed class MigrationToState {
|
||||
@Serializable object ChatStopInProgress: MigrationToState()
|
||||
@Serializable data class ChatStopFailed(val reason: String): MigrationToState()
|
||||
@Serializable object PassphraseNotSet: MigrationToState()
|
||||
@Serializable object PassphraseConfirmation: MigrationToState()
|
||||
@Serializable object UploadConfirmation: MigrationToState()
|
||||
@Serializable object Archiving: MigrationToState()
|
||||
@Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState()
|
||||
@Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState()
|
||||
@Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState()
|
||||
@Serializable object LinkCreation: MigrationToState()
|
||||
@Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState()
|
||||
@Serializable data class Finished(val chatDeletion: Boolean): MigrationToState()
|
||||
}
|
||||
|
||||
private var MutableState<MigrationToState>.state: MigrationToState
|
||||
get() = value
|
||||
set(v) { value = v }
|
||||
|
||||
@Composable
|
||||
fun MigrateToAnotherDeviceView(close: () -> Unit) {
|
||||
val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf<MigrationToState>(MigrationToState.ChatStopInProgress) }
|
||||
// Prevent from hiding the view until migration is finished or app deleted
|
||||
val backDisabled = remember {
|
||||
derivedStateOf {
|
||||
migrationState.value is MigrationToState.DatabaseInit ||
|
||||
migrationState.value is MigrationToState.Archiving ||
|
||||
migrationState.value is MigrationToState.LinkCreation ||
|
||||
migrationState.value is MigrationToState.LinkShown ||
|
||||
migrationState.value is MigrationToState.Finished
|
||||
}
|
||||
}
|
||||
val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) }
|
||||
ModalView(
|
||||
enableClose = !backDisabled.value,
|
||||
close = {
|
||||
withBGApi {
|
||||
migrationState.cleanUpOnBack(chatReceiver.value)
|
||||
}
|
||||
close()
|
||||
},
|
||||
) {
|
||||
MigrateToAnotherDeviceLayout(
|
||||
migrationState = migrationState,
|
||||
chatReceiver = chatReceiver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateToAnotherDeviceLayout(
|
||||
migrationState: MutableState<MigrationToState>,
|
||||
chatReceiver: MutableState<MigrationToChatReceiver?>
|
||||
) {
|
||||
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.migrate_to_device))
|
||||
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
platform.androidLockPortraitOrientation()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionByState(
|
||||
migrationState: MutableState<MigrationToState>,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationToChatReceiver?>
|
||||
) {
|
||||
when (val s = migrationState.value) {
|
||||
is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView()
|
||||
is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason)
|
||||
is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView()
|
||||
is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView()
|
||||
is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView()
|
||||
is MigrationToState.Archiving -> migrationState.ArchivingView()
|
||||
is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath)
|
||||
is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath)
|
||||
is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value)
|
||||
is MigrationToState.LinkCreation -> LinkCreationView()
|
||||
is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl)
|
||||
is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.ChatStopInProgressView() {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
stopChat()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.ChatStopFailedView(reason: String) {
|
||||
SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) {
|
||||
Text(reason)
|
||||
SectionSpacer()
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_report_filled),
|
||||
text = stringResource(MR.strings.auth_stop_chat),
|
||||
textColor = MaterialTheme.colors.error,
|
||||
click = ::stopChat
|
||||
){}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.PassphraseNotSetView() {
|
||||
DatabaseEncryptionView(chatModel, true)
|
||||
KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) {
|
||||
if (!appPreferences.initialRandomDBPassphrase.get()) {
|
||||
state = MigrationToState.UploadConfirmation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.PassphraseConfirmationView() {
|
||||
val useKeychain = remember { appPreferences.storeDBPassphrase.get() }
|
||||
val currentKey = rememberSaveable { mutableStateOf("") }
|
||||
val verifyingPassphrase = rememberSaveable { mutableStateOf(false) }
|
||||
Box {
|
||||
val view = LocalMultiplatformView()
|
||||
Column {
|
||||
ChatStoppedView()
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) {
|
||||
PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true)
|
||||
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock),
|
||||
text = stringResource(MR.strings.migration_to_device_verify_passphrase),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
disabled = verifyingPassphrase.value || currentKey.value.isEmpty(),
|
||||
click = {
|
||||
verifyingPassphrase.value = true
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
verifyDatabasePassphrase(currentKey.value)
|
||||
verifyingPassphrase.value = false
|
||||
}
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase))
|
||||
}
|
||||
}
|
||||
if (verifyingPassphrase.value) {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.UploadConfirmationView() {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_ios_share),
|
||||
text = stringResource(MR.strings.migration_to_device_archive_and_upload),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = { state = MigrationToState.Archiving }
|
||||
){}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.ArchivingView() {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
exportArchive()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
prepareDatabase(tempDatabaseFile, totalBytes, archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.UploadProgressView(
|
||||
uploadedBytes: Long,
|
||||
totalBytes: Long,
|
||||
ctrl: ChatCtrl,
|
||||
user: User,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationToChatReceiver?>,
|
||||
archivePath: String,
|
||||
) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) {
|
||||
val ratio = uploadedBytes.toFloat() / max(totalBytes, 1)
|
||||
LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes)))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
startUploading(totalBytes, ctrl, user, tempDatabaseFile, chatReceiver, archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_ios_share),
|
||||
text = stringResource(MR.strings.migration_to_device_repeat_upload),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
state = MigrationToState.DatabaseInit(totalBytes, archivePath)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again))
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkCreationView() {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {}
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) {
|
||||
SectionView {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_close),
|
||||
text = stringResource(MR.strings.migration_to_device_cancel_migration),
|
||||
textColor = MaterialTheme.colors.error,
|
||||
click = {
|
||||
cancelMigration(fileId, ctrl)
|
||||
}
|
||||
) {}
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_check),
|
||||
text = stringResource(MR.strings.migration_to_device_finalize_migration),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
finishMigration(fileId, ctrl)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device))
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(stringResource(MR.strings.show_QR_code).uppercase()) {
|
||||
SimpleXLinkQRCode(link, onShare = {})
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) {
|
||||
LinkTextView(link, true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationToState>.FinishedView(chatDeletion: Boolean) {
|
||||
Box {
|
||||
SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_delete_forever),
|
||||
text = stringResource(MR.strings.migration_to_device_delete_database_from_device),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
click = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_chat_profile_question),
|
||||
text = generalGetString(MR.strings.delete_chat_profile_action_cannot_be_undone_warning),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = {
|
||||
deleteChatAndDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
) {}
|
||||
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_play_arrow_filled),
|
||||
text = stringResource(MR.strings.migration_to_device_start_chat),
|
||||
textColor = MaterialTheme.colors.error,
|
||||
click = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.start_chat_question),
|
||||
text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported),
|
||||
confirmText = generalGetString(MR.strings.migration_to_device_start_chat),
|
||||
onConfirm = {
|
||||
withLongRunningApi { startChatAndDismiss() }
|
||||
}
|
||||
)
|
||||
}
|
||||
) {}
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device))
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption))
|
||||
}
|
||||
if (chatDeletion) {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressView() {
|
||||
DefaultProgressView(null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LargeProgressView(value: Float, title: String, description: String) {
|
||||
Box(Modifier.padding(DEFAULT_PADDING).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress = value,
|
||||
(if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH) else Modifier.size(windowWidth() - DEFAULT_PADDING * 2))
|
||||
.rotate(-90f),
|
||||
color = MaterialTheme.colors.primary,
|
||||
strokeWidth = 25.dp
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(description, color = Color.Transparent)
|
||||
Text(title, style = MaterialTheme.typography.h1.copy(fontSize = 50.sp, fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary)
|
||||
Text(description, style = MaterialTheme.typography.subtitle1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.stopChat() {
|
||||
withBGApi {
|
||||
try {
|
||||
stopChatAsync(chatModel)
|
||||
try {
|
||||
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
|
||||
state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.migrate_to_device_error_saving_settings),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MutableState<MigrationToState>.verifyDatabasePassphrase(dbKey: String) {
|
||||
if (controller.testStorageEncryption(dbKey)) {
|
||||
state = MigrationToState.UploadConfirmation
|
||||
} else {
|
||||
showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase(""))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.exportArchive() {
|
||||
withLongRunningApi {
|
||||
try {
|
||||
getMigrationTempFilesDirectory().mkdir()
|
||||
val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf(""))
|
||||
val totalBytes = File(archivePath).length()
|
||||
if (totalBytes > 0L) {
|
||||
state = MigrationToState.DatabaseInit(totalBytes, archivePath)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist))
|
||||
state = MigrationToState.UploadConfirmation
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
state = MigrationToState.UploadConfirmation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair<ChatCtrl, User>? {
|
||||
val (status, ctrl) = chatInitTemporaryDatabase(tempDatabaseFile.absolutePath)
|
||||
showErrorOnMigrationIfNeeded(status)
|
||||
try {
|
||||
if (ctrl != null) {
|
||||
val user = startChatWithTemporaryDatabase(ctrl, netCfg)
|
||||
return if (user != null) ctrl to user else null
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Error while starting chat in temporary database: ${e.stackTraceToString()}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.prepareDatabase(
|
||||
tempDatabaseFile: File,
|
||||
totalBytes: Long,
|
||||
archivePath: String,
|
||||
) {
|
||||
withLongRunningApi {
|
||||
val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg())
|
||||
if (ctrlAndUser == null) {
|
||||
state = MigrationToState.UploadFailed(totalBytes, archivePath)
|
||||
return@withLongRunningApi
|
||||
}
|
||||
|
||||
val (ctrl, user) = ctrlAndUser
|
||||
state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.startUploading(
|
||||
totalBytes: Long,
|
||||
ctrl: ChatCtrl,
|
||||
user: User,
|
||||
tempDatabaseFile: File,
|
||||
chatReceiver: MutableState<MigrationToChatReceiver?>,
|
||||
archivePath: String,
|
||||
) {
|
||||
withBGApi {
|
||||
chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg ->
|
||||
when (msg) {
|
||||
is CR.SndFileProgressXFTP -> {
|
||||
val s = state
|
||||
if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) {
|
||||
state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user)
|
||||
}
|
||||
}
|
||||
is CR.SndFileRedirectStartXFTP -> {
|
||||
delay(500)
|
||||
state = MigrationToState.LinkCreation
|
||||
}
|
||||
is CR.SndStandaloneFileComplete -> {
|
||||
delay(500)
|
||||
val cfg = getNetCfg()
|
||||
val data = MigrationFileLinkData(
|
||||
networkConfig = MigrationFileLinkData.NetworkConfig(
|
||||
socksProxy = cfg.socksProxy,
|
||||
hostMode = cfg.hostMode,
|
||||
requiredHostMode = cfg.requiredHostMode
|
||||
)
|
||||
)
|
||||
state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "unsupported event: ${msg.responseType}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatReceiver.value?.start()
|
||||
|
||||
val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl)
|
||||
if (res == null) {
|
||||
state = MigrationToState.UploadFailed(totalBytes, archivePath)
|
||||
return@withBGApi AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.migration_to_device_error_uploading_archive),
|
||||
error
|
||||
)
|
||||
}
|
||||
state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cancelUploadedArchive(fileId: Long, ctrl: ChatCtrl) {
|
||||
controller.apiCancelFile(null, fileId, ctrl)
|
||||
}
|
||||
|
||||
private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) {
|
||||
withBGApi {
|
||||
cancelUploadedArchive(fileId, ctrl)
|
||||
startChatAndDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.finishMigration(fileId: Long, ctrl: ChatCtrl) {
|
||||
withBGApi {
|
||||
cancelUploadedArchive(fileId, ctrl)
|
||||
state = MigrationToState.Finished(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationToState>.deleteChatAndDismiss() {
|
||||
withBGApi {
|
||||
try {
|
||||
deleteChatAsync(chatModel)
|
||||
chatModel.chatDbChanged.value = true
|
||||
state = MigrationToState.Finished(true)
|
||||
try {
|
||||
initChatController(startChat = { CompletableDeferred(false) })
|
||||
chatModel.chatDbChanged.value = false
|
||||
ModalManager.fullscreen.closeModals()
|
||||
} catch (e: Exception) {
|
||||
throw Exception(generalGetString(MR.strings.error_starting_chat) + "\n" + e.stackTraceToString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.migration_to_device_error_deleting_database),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startChatAndDismiss(dismiss: Boolean = true) {
|
||||
try {
|
||||
val user = chatModel.currentUser.value
|
||||
if (chatModel.chatDbChanged.value) {
|
||||
initChatController()
|
||||
chatModel.chatDbChanged.value = false
|
||||
} else if (user != null) {
|
||||
startChat(user)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_starting_chat),
|
||||
text = e.stackTraceToString()
|
||||
)
|
||||
}
|
||||
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
|
||||
if (dismiss || chatModel.chatDbStatus.value != DBMigrationResult.OK) {
|
||||
ModalManager.fullscreen.closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MutableState<MigrationToState>.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) {
|
||||
val s = state
|
||||
if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) {
|
||||
chatModel.switchingUsersAndHosts.value = true
|
||||
startChatAndDismiss(false)
|
||||
chatModel.switchingUsersAndHosts.value = false
|
||||
}
|
||||
if (s is MigrationToState.UploadProgress) {
|
||||
cancelUploadedArchive(s.fileId, s.ctrl)
|
||||
}
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
}
|
||||
|
||||
private fun fileForTemporaryDatabase(): File =
|
||||
File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory()))
|
||||
|
||||
private class MigrationToChatReceiver(
|
||||
val ctrl: ChatCtrl,
|
||||
val databaseUrl: File,
|
||||
var receiveMessages: Boolean = true,
|
||||
val processReceivedMsg: suspend (CR) -> Unit
|
||||
) {
|
||||
fun start() {
|
||||
Log.d(TAG, "MigrationChatReceiver startReceiver")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (receiveMessages) {
|
||||
try {
|
||||
val msg = ChatController.recvMsg(ctrl)
|
||||
if (msg != null && receiveMessages) {
|
||||
val r = msg.resp
|
||||
val rhId = msg.remoteHostId
|
||||
Log.d(TAG, "processReceivedMsg: ${r.responseType}")
|
||||
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
|
||||
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
|
||||
processReceivedMsg(r)
|
||||
}
|
||||
if (finishedWithoutTimeout == null) {
|
||||
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
|
||||
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.possible_slow_function_title),
|
||||
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
|
||||
shareText = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString())
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAndCleanUp() {
|
||||
Log.d(TAG, "MigrationChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
chatCloseStore(ctrl)
|
||||
File(databaseUrl.absolutePath + "_chat.db").delete()
|
||||
File(databaseUrl.absolutePath + "_agent.db").delete()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -301,7 +301,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkTextView(link: String, share: Boolean) {
|
||||
fun LinkTextView(link: String, share: Boolean) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Row(Modifier.fillMaxWidth().heightIn(min = 46.dp).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f).clickable {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
|
||||
prefs.storeDBPassphrase.set(false)
|
||||
|
||||
val newKeyValue = newKey.value
|
||||
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator)
|
||||
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator, false)
|
||||
if (success) {
|
||||
startChat(newKeyValue)
|
||||
nextStep()
|
||||
|
||||
+19
-2
@@ -5,7 +5,7 @@ import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -16,8 +16,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
|
||||
@@ -62,17 +64,32 @@ fun SimpleXInfoLayout(
|
||||
OnboardingActionButton(user, onboardingStage)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download),
|
||||
click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } })
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = DEFAULT_PADDING.times(1.5f), top = DEFAULT_PADDING), contentAlignment = Alignment.Center
|
||||
.padding(bottom = DEFAULT_PADDING.times(1.5f), top = if (onboardingStage == null) DEFAULT_PADDING else 0.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButtonDecorated(text = stringResource(MR.strings.how_it_works), icon = painterResource(MR.images.ic_info),
|
||||
click = showModal { HowItWorks(user, onboardingStage) })
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
val state = chatModel.migrationState.value
|
||||
if (state != null && !ModalManager.fullscreen.hasModalsOpen()) {
|
||||
ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+95
-61
@@ -33,12 +33,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun NetworkAndServersView(
|
||||
chatModel: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
fun NetworkAndServersView() {
|
||||
val currentRemoteHost by remember { chatModel.currentRemoteHost }
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
@@ -55,9 +50,6 @@ fun NetworkAndServersView(
|
||||
onionHosts = onionHosts,
|
||||
sessionMode = sessionMode,
|
||||
proxyPort = proxyPort,
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
showCustomModal = showCustomModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
@@ -154,13 +146,11 @@ fun NetworkAndServersView(
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
proxyPort: State<Int>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
val m = chatModel
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -168,17 +158,18 @@ fun NetworkAndServersView(
|
||||
AppBarTitle(stringResource(MR.strings.network_and_servers))
|
||||
if (!chatModel.desktopNoUserNoRemote) {
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
|
||||
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
|
||||
|
||||
if (currentRemoteHost == null) {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false)
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
|
||||
SessionModePicker(sessionMode, showModal, updateSessionMode)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showModal { AdvancedNetworkSettingsView(m) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,18 +187,39 @@ fun NetworkAndServersView(
|
||||
}
|
||||
|
||||
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), showModal { RTCServersView(it) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun OnionRelatedLayout(
|
||||
developerTools: Boolean,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
networkProxyHostPort: SharedPreference<String?>,
|
||||
proxyPort: State<Int>,
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.fullscreen.showModal(content = it) }
|
||||
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, networkProxyHostPort, true)
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showModal, updateSessionMode)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseSocksProxySwitch(
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
proxyPort: State<Int>,
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
|
||||
showModal: (@Composable ModalData.() -> Unit) -> Unit,
|
||||
networkProxyHostPort: SharedPreference<String?> = chatModel.controller.appPrefs.networkProxyHostPort,
|
||||
migration: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING),
|
||||
@@ -227,8 +239,11 @@ fun UseSocksProxySwitch(
|
||||
val text = buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.network_socks_toggle_use_socks_proxy) + " (")
|
||||
val style = SpanStyle(color = MaterialTheme.colors.primary)
|
||||
val disabledStyle = SpanStyle(color = MaterialTheme.colors.onBackground)
|
||||
withAnnotation(tag = "PORT", annotation = generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) {
|
||||
withStyle(style) { append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) }
|
||||
withStyle(if (networkUseSocksProxy.value || !migration) style else disabledStyle) {
|
||||
append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value))
|
||||
}
|
||||
}
|
||||
append(")")
|
||||
}
|
||||
@@ -238,7 +253,9 @@ fun UseSocksProxySwitch(
|
||||
onClick = { offset ->
|
||||
text.getStringAnnotations(tag = "PORT", start = offset, end = offset)
|
||||
.firstOrNull()?.let { _ ->
|
||||
showSettingsModal { SockProxySettings(it) }()
|
||||
if (networkUseSocksProxy.value || !migration) {
|
||||
showModal { SockProxySettings(chatModel, networkProxyHostPort, migration) }
|
||||
}
|
||||
}
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
@@ -254,7 +271,11 @@ fun UseSocksProxySwitch(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SockProxySettings(m: ChatModel) {
|
||||
fun SockProxySettings(
|
||||
m: ChatModel,
|
||||
networkProxyHostPort: SharedPreference<String?> = m.controller.appPrefs.networkProxyHostPort,
|
||||
migration: Boolean,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -262,17 +283,17 @@ fun SockProxySettings(m: ChatModel) {
|
||||
) {
|
||||
val defaultHostPort = remember { "localhost:9050" }
|
||||
AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings))
|
||||
val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state }
|
||||
val hostPortSaved by remember { networkProxyHostPort.state }
|
||||
val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost"))
|
||||
mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.firstOrNull() ?: "localhost"))
|
||||
}
|
||||
val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050"))
|
||||
mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.lastOrNull() ?: "9050"))
|
||||
}
|
||||
val save = {
|
||||
withBGApi {
|
||||
m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
|
||||
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
|
||||
networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
|
||||
if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) {
|
||||
m.controller.apiSetNetworkConfig(m.controller.getNetCfg())
|
||||
}
|
||||
}
|
||||
@@ -281,21 +302,21 @@ fun SockProxySettings(m: ChatModel) {
|
||||
SectionItemView {
|
||||
ResetToDefaultsButton({
|
||||
val reset = {
|
||||
m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort)
|
||||
networkProxyHostPort.set(defaultHostPort)
|
||||
val newHost = defaultHostPort.split(":").first()
|
||||
val newPort = defaultHostPort.split(":").last()
|
||||
hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length))
|
||||
portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length))
|
||||
save()
|
||||
}
|
||||
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
|
||||
if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) {
|
||||
showUpdateNetworkSettingsDialog {
|
||||
reset()
|
||||
}
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
}, disabled = hostPort == defaultHostPort)
|
||||
}, disabled = hostPortSaved == defaultHostPort)
|
||||
}
|
||||
SectionItemView {
|
||||
DefaultConfigurableTextField(
|
||||
@@ -321,14 +342,14 @@ fun SockProxySettings(m: ChatModel) {
|
||||
SectionCustomFooter {
|
||||
NetworkSectionFooter(
|
||||
revert = {
|
||||
val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost"
|
||||
val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050"
|
||||
val prevHost = hostPortSaved?.split(":")?.firstOrNull() ?: "localhost"
|
||||
val prevPort = hostPortSaved?.split(":")?.lastOrNull() ?: "9050"
|
||||
hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length))
|
||||
portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length))
|
||||
},
|
||||
save = { if (m.controller.appPrefs.networkUseSocksProxy.get()) showUpdateNetworkSettingsDialog { save() } else save() },
|
||||
revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
|
||||
saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
|
||||
save = { if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) showUpdateNetworkSettingsDialog { save() } else save() },
|
||||
revertDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
|
||||
saveDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
|
||||
remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value ||
|
||||
remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value
|
||||
)
|
||||
@@ -341,7 +362,7 @@ fun SockProxySettings(m: ChatModel) {
|
||||
private fun UseOnionHosts(
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
enabled: State<Boolean>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showModal: (@Composable ModalData.() -> Unit) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
) {
|
||||
val values = remember {
|
||||
@@ -353,29 +374,43 @@ private fun UseOnionHosts(
|
||||
}
|
||||
}
|
||||
}
|
||||
val onSelected = showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.network_use_onion_hosts))
|
||||
SectionViewSelectable(null, onionHosts, values, useOnion)
|
||||
val onSelected = {
|
||||
showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.network_use_onion_hosts))
|
||||
SectionViewSelectable(null, onionHosts, values, useOnion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionItemWithValue(
|
||||
generalGetString(MR.strings.network_use_onion_hosts),
|
||||
onionHosts,
|
||||
values,
|
||||
icon = painterResource(MR.images.ic_security),
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
if (enabled.value) {
|
||||
SectionItemWithValue(
|
||||
generalGetString(MR.strings.network_use_onion_hosts),
|
||||
onionHosts,
|
||||
values,
|
||||
icon = painterResource(MR.images.ic_security),
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
} else {
|
||||
// In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before
|
||||
SectionItemWithValue(
|
||||
generalGetString(MR.strings.network_use_onion_hosts),
|
||||
remember { mutableStateOf(OnionHosts.NEVER) },
|
||||
listOf(ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))),
|
||||
icon = painterResource(MR.images.ic_security),
|
||||
enabled = enabled,
|
||||
onSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionModePicker(
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showModal: (@Composable ModalData.() -> Unit) -> Unit,
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -393,12 +428,14 @@ private fun SessionModePicker(
|
||||
sessionMode,
|
||||
values,
|
||||
icon = painterResource(MR.images.ic_safety_divider),
|
||||
onSelected = showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation))
|
||||
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
|
||||
onSelected = {
|
||||
showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation))
|
||||
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -455,9 +492,6 @@ fun PreviewNetworkAndServersLayout() {
|
||||
developerTools = true,
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
proxyPort = remember { mutableStateOf(9050) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
|
||||
|
||||
+6
-3
@@ -28,6 +28,8 @@ import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.database.DatabaseView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView
|
||||
import chat.simplex.common.views.migration.MigrateToAnotherDeviceView
|
||||
import chat.simplex.common.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.common.views.onboarding.WhatsNewView
|
||||
import chat.simplex.common.views.remote.ConnectDesktopView
|
||||
@@ -135,12 +137,13 @@ fun SettingsLayout(
|
||||
} else {
|
||||
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true)
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
|
||||
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true)
|
||||
@@ -366,7 +369,7 @@ fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) {
|
||||
fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) {
|
||||
SectionItemView(
|
||||
click,
|
||||
extraPadding = extraPadding,
|
||||
@@ -382,7 +385,7 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (
|
||||
}
|
||||
if (text != null) {
|
||||
val padding = with(LocalDensity.current) { 6.sp.toDp() }
|
||||
Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground)
|
||||
Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else textColor)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) {
|
||||
content()
|
||||
|
||||
@@ -54,8 +54,10 @@
|
||||
<string name="decryption_error">Decryption error</string>
|
||||
<string name="encryption_renegotiation_error">Encryption re-negotiation error</string>
|
||||
|
||||
<string name="e2ee_info_no_pq">This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.</string>
|
||||
<string name="e2ee_info_pq">This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.</string>
|
||||
<string name="e2ee_info_no_pq"><![CDATA[Messages, files and calls are protected by <b>end-to-end encryption</b> with perfect forward secrecy, repudiation and break-in recovery.]]></string>
|
||||
<string name="e2ee_info_pq"><![CDATA[Messages, files and calls are protected by <b>quantum resistant e2e encryption</b> with perfect forward secrecy, repudiation and break-in recovery.]]></string>
|
||||
<string name="e2ee_info_no_pq_short">This chat is protected by end-to-end encryption.</string>
|
||||
<string name="e2ee_info_pq_short">This chat is protected by quantum resistant end-to-end encryption.</string>
|
||||
|
||||
<!-- NoteFolder - ChatModel.kt -->
|
||||
<string name="note_folder_local_display_name">Private notes</string>
|
||||
@@ -243,6 +245,7 @@
|
||||
<string name="auth_stop_chat">Stop chat</string>
|
||||
<string name="auth_open_chat_console">Open chat console</string>
|
||||
<string name="auth_open_chat_profiles">Open chat profiles</string>
|
||||
<string name="auth_open_migration_to_another_device">Open migration screen</string>
|
||||
<string name="lock_not_enabled">SimpleX Lock not enabled!</string>
|
||||
<string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string>
|
||||
|
||||
@@ -821,6 +824,7 @@
|
||||
<string name="opensource_protocol_and_code_anybody_can_run_servers">Open-source protocol and code – anybody can run the servers.</string>
|
||||
<string name="create_your_profile">Create your profile</string>
|
||||
<string name="make_private_connection">Make a private connection</string>
|
||||
<string name="migrate_from_another_device">Migrate from another device</string>
|
||||
<string name="how_it_works">How it works</string>
|
||||
|
||||
<!-- How SimpleX Works -->
|
||||
@@ -1079,6 +1083,7 @@
|
||||
<string name="confirm_new_passphrase">Confirm new passphrase…</string>
|
||||
<string name="update_database_passphrase">Update database passphrase</string>
|
||||
<string name="set_database_passphrase">Set database passphrase</string>
|
||||
<string name="set_passphrase">Set passphrase</string>
|
||||
<string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string>
|
||||
<string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string>
|
||||
<string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string>
|
||||
@@ -1840,4 +1845,64 @@
|
||||
<string name="agent_internal_error_title">Internal error</string>
|
||||
<string name="agent_internal_error_desc">Please report it to the developers: \n%s</string>
|
||||
<string name="restart_chat_button">Restart chat</string>
|
||||
|
||||
|
||||
<!-- MigrateFromAnotherDevice.kt -->
|
||||
<string name="migrate_here">Migrate here</string>
|
||||
<string name="or_paste_archive_link">Or paste archive link</string>
|
||||
<string name="paste_archive_link">Paste archive link</string>
|
||||
<string name="invalid_file_link">Invalid link</string>
|
||||
<string name="migration_from_device_migrating">Migrating</string>
|
||||
<string name="migration_from_device_database_init">Preparing download</string>
|
||||
<string name="migration_from_device_downloading_details">Downloading link details</string>
|
||||
<string name="migration_from_device_downloading_archive">Downloading archive</string>
|
||||
<string name="migration_from_device_bytes_downloaded">%s downloaded</string>
|
||||
<string name="migration_from_device_download_failed">Download failed</string>
|
||||
<string name="migration_from_device_repeat_download">Repeat download</string>
|
||||
<string name="migration_from_device_try_again">You can give another try.</string>
|
||||
<string name="migration_from_device_importing_archive">Importing archive</string>
|
||||
<string name="migration_from_device_import_failed">Import failed</string>
|
||||
<string name="migration_from_device_repeat_import">Repeat import</string>
|
||||
<string name="migration_from_device_enter_passphrase">Enter passphrase</string>
|
||||
<string name="migration_from_device_file_delete_or_link_invalid">File was deleted or link is invalid</string>
|
||||
<string name="migration_from_device_error_downloading_archive">Error downloading the archive</string>
|
||||
<string name="migration_from_device_chat_migrated">Chat migrated!</string>
|
||||
<string name="migration_from_device_finalize_migration">Finalize migration on another device.</string>
|
||||
<string name="migration_from_device_confirm_network_settings">Confirm network settings</string>
|
||||
<string name="migration_from_device_confirm_network_settings_footer">Please confirm that network settings are correct for this device.</string>
|
||||
<string name="migration_from_device_apply_onion">Apply</string>
|
||||
|
||||
<!-- MigrateToAnotherDevice.kt -->
|
||||
<string name="migrate_to_device">Migrate to another device</string>
|
||||
<string name="migrate_to_device_error_saving_settings">Error saving settings</string>
|
||||
<string name="migrate_to_device_exported_file_doesnt_exist">Exported file doesn\'t exist</string>
|
||||
<string name="migrate_to_device_error_exporting_archive">Error exporting chat database</string>
|
||||
<string name="migration_to_device_database_init">Preparing upload</string>
|
||||
<string name="migration_to_device_error_uploading_archive">Error uploading the archive</string>
|
||||
<string name="migration_to_device_error_deleting_database">Error deleting database</string>
|
||||
<string name="migration_to_device_stopping_chat">Stopping chat</string>
|
||||
<string name="migration_to_device_chat_should_be_stopped">In order to continue, chat should be stopped.</string>
|
||||
<string name="migration_to_device_archive_and_upload">Archive and upload</string>
|
||||
<string name="migration_to_device_confirm_upload">Confirm upload</string>
|
||||
<string name="migration_to_device_all_data_will_be_uploaded">All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays.</string>
|
||||
<string name="migration_to_device_archiving_database">Archiving database</string>
|
||||
<string name="migration_to_device_bytes_uploaded">%s uploaded</string>
|
||||
<string name="migration_to_device_uploading_archive">Uploading archive</string>
|
||||
<string name="migration_to_device_upload_failed">Upload failed</string>
|
||||
<string name="migration_to_device_repeat_upload">Repeat upload</string>
|
||||
<string name="migration_to_device_try_again">You can give another try.</string>
|
||||
<string name="migration_to_device_creating_archive_link">Creating archive link</string>
|
||||
<string name="migration_to_device_cancel_migration">Cancel migration</string>
|
||||
<string name="migration_to_device_finalize_migration">Finalize migration</string>
|
||||
<string name="migration_to_device_choose_migrate_from_another_device"><![CDATA[Choose <i>Migrate from another device</i> on the new device and scan QR code.]]></string>
|
||||
<string name="migration_to_device_or_share_this_file_link">Or securely share this file link</string>
|
||||
<string name="migration_to_device_delete_database_from_device">Delete database from this device</string>
|
||||
<string name="migration_to_device_starting_chat_on_multiple_devices_unsupported">Warning: starting chat on multiple devices is not supported and will cause message delivery failures</string>
|
||||
<string name="migration_to_device_start_chat">Start chat</string>
|
||||
<string name="migration_to_device_migration_complete">Migration complete</string>
|
||||
<string name="migration_to_device_you_must_not_start_database_on_two_device"><![CDATA[You <b>must not</b> use the same database on two devices.]]></string>
|
||||
<string name="migration_to_device_using_on_two_device_breaks_encryption"><![CDATA[<b>Please note</b>: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]></string>
|
||||
<string name="migration_to_device_verify_database_passphrase">Verify database passphrase</string>
|
||||
<string name="migration_to_device_verify_passphrase">Verify passphrase</string>
|
||||
<string name="migration_to_device_confirm_you_remember_passphrase">Confirm that you remember database passphrase to migrate it.</string>
|
||||
</resources>
|
||||
+11
-4
@@ -2,6 +2,7 @@ package chat.simplex.common.views.database
|
||||
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import TextIconSpaced
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -22,8 +23,9 @@ actual fun SavePassphraseSetting(
|
||||
useKeychain: Boolean,
|
||||
initialRandomDBPassphrase: Boolean,
|
||||
storedKey: Boolean,
|
||||
progressIndicator: Boolean,
|
||||
minHeight: Dp,
|
||||
enabled: Boolean,
|
||||
smallPadding: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionItemView(minHeight = minHeight) {
|
||||
@@ -33,7 +35,11 @@ actual fun SavePassphraseSetting(
|
||||
stringResource(MR.strings.save_passphrase_in_settings),
|
||||
tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
if (smallPadding) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
} else {
|
||||
TextIconSpaced(false)
|
||||
}
|
||||
Text(
|
||||
stringResource(MR.strings.save_passphrase_in_settings),
|
||||
Modifier.padding(end = 24.dp),
|
||||
@@ -43,7 +49,7 @@ actual fun SavePassphraseSetting(
|
||||
DefaultSwitch(
|
||||
checked = useKeychain,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter(
|
||||
chatDbEncrypted: Boolean?,
|
||||
storedKey: MutableState<Boolean>,
|
||||
initialRandomDBPassphrase: MutableState<Boolean>,
|
||||
migration: Boolean,
|
||||
) {
|
||||
if (chatDbEncrypted == false) {
|
||||
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
|
||||
} else if (useKeychain.value) {
|
||||
if (storedKey.value) {
|
||||
SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text))
|
||||
if (initialRandomDBPassphrase.value) {
|
||||
if (initialRandomDBPassphrase.value && !migration) {
|
||||
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
|
||||
} else {
|
||||
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 78eb4f764fd52385a8687d2605a0e6edc1808431
|
||||
tag: 0aa4ae72286237d066c3ce2bff355638523c7095
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 5.6.0.0
|
||||
version: 5.6.0.1
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."78eb4f764fd52385a8687d2605a0e6edc1808431" = "09nmrk65nbn6mp8mwwk09d5zx9cgm38i6xgmndk6jzlhnfl5fiy6";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."0aa4ae72286237d066c3ce2bff355638523c7095" = "1jcy5p8220w8ahi4fgil5rxlj83c9qy44s6mly9jh8n9a2bwdr4d";
|
||||
"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/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 5.6.0.0
|
||||
version: 5.6.0.1
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
||||
+1
-1
@@ -3423,7 +3423,7 @@ processAgentMsgSndFile _corrId aFileId msg =
|
||||
liftIO $ updateFileCancelled db user fileId CIFSSndError
|
||||
lookupChatItemByFileId db vr user fileId
|
||||
withAgent (`xftpDeleteSndFileInternal` aFileId)
|
||||
toView $ CRSndFileError user ci ft
|
||||
toView $ CRSndFileError user ci ft err
|
||||
|
||||
splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr)
|
||||
splitFileDescr rfdText = do
|
||||
|
||||
@@ -619,7 +619,7 @@ data ChatResponse
|
||||
| CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]}
|
||||
| CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta}
|
||||
| CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text}
|
||||
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
|
||||
| CRUserProfileImage {user :: User, profile :: Profile}
|
||||
| CRContactAliasUpdated {user :: User, toContact :: Contact}
|
||||
|
||||
@@ -72,11 +72,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis
|
||||
|
||||
-- when acting as host
|
||||
minRemoteCtrlVersion :: AppVersion
|
||||
minRemoteCtrlVersion = AppVersion [5, 5, 0, 2]
|
||||
minRemoteCtrlVersion = AppVersion [5, 6, 0, 0]
|
||||
|
||||
-- when acting as controller
|
||||
minRemoteHostVersion :: AppVersion
|
||||
minRemoteHostVersion = AppVersion [5, 5, 0, 2]
|
||||
minRemoteHostVersion = AppVersion [5, 6, 0, 0]
|
||||
|
||||
currentAppVersion :: AppVersion
|
||||
currentAppVersion = AppVersion SC.version
|
||||
|
||||
@@ -471,7 +471,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe
|
||||
FROM group_members m
|
||||
JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
|
||||
LEFT JOIN contacts c ON m.contact_id = c.contact_id
|
||||
LEFT JOIN chat_items i ON i.group_id = m.group_id
|
||||
LEFT JOIN chat_items i ON i.user_id = m.user_id
|
||||
AND i.group_id = m.group_id
|
||||
AND m.group_member_id = i.group_member_id
|
||||
AND i.shared_msg_id = :msg_id
|
||||
WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id
|
||||
|
||||
@@ -215,8 +215,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris
|
||||
CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci
|
||||
CRSndFileCancelledXFTP {} -> []
|
||||
CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft
|
||||
CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci
|
||||
CRSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e]
|
||||
CRSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e]
|
||||
CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} ->
|
||||
ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft]
|
||||
CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [plain . LB.toStrict $ J.encode j]) info_
|
||||
|
||||
Reference in New Issue
Block a user