UI
This commit is contained in:
parent
0f1f9893cc
commit
665cdd9b0e
@ -107,32 +107,32 @@ struct DatabaseEncryptionView: View {
|
||||
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 !migration {
|
||||
if !migration {
|
||||
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.")
|
||||
}
|
||||
} 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.")
|
||||
}
|
||||
} 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.")
|
||||
}
|
||||
} 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("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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.font(.callout)
|
||||
}
|
||||
.onAppear {
|
||||
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
|
||||
|
@ -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 {
|
||||
|
@ -18,30 +18,62 @@ public enum MigrationState: Equatable {
|
||||
case uploadConfirmation
|
||||
case archiving
|
||||
case uploadProgress(uploadedBytes: Int64, totalBytes: Int64, archivePath: URL)
|
||||
case linkCreation(totalBytes: Int64, archivePath: URL)
|
||||
case linkShown(link: String, archivePath: URL)
|
||||
case linkCreation(totalBytes: Int64, fileId: Int64, archivePath: URL)
|
||||
case linkShown(fileId: Int64, link: String, archivePath: URL)
|
||||
case finished
|
||||
}
|
||||
|
||||
enum MigrateToAnotherDeviceViewAlert: Identifiable {
|
||||
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
|
||||
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
|
||||
|
||||
case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.")
|
||||
case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation")
|
||||
case keychainError(_ title: LocalizedStringKey = "Keychain error")
|
||||
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
|
||||
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
|
||||
|
||||
case error(title: LocalizedStringKey, error: String = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .deleteChat(title, text): return "\(title) \(text)"
|
||||
case let .startChat(title, text): return "\(title) \(text)"
|
||||
|
||||
case .wrongPassphrase: return "wrongPassphrase"
|
||||
case .invalidConfirmation: return "invalidConfirmation"
|
||||
case .keychainError: return "keychainError"
|
||||
case let .databaseError(title, message): return "\(title) \(message)"
|
||||
case let .unknownError(title, message): return "\(title) \(message)"
|
||||
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MigrateToAnotherDevice: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var showSettings: Bool
|
||||
@State private var migrationState: MigrationState = .initial
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var initialRandomDBPassphrase = initialRandomDBPassphraseGroupDefault.get()
|
||||
@AppStorage(GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE, store: groupDefaults) private var initialRandomDBPassphrase: Bool = false
|
||||
@State private var alert: MigrateToAnotherDeviceViewAlert?
|
||||
@State private var confirmPassphraseAlert: PassphraseConfirmationView.PassphraseConfirmationViewAlert?
|
||||
@State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
private let chatWasStoppedInitially: Bool = AppChatState.shared.value == .stopped
|
||||
|
||||
enum MigrateToAnotherDeviceViewAlert: Identifiable {
|
||||
case error(title: LocalizedStringKey, error: String = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
var body: some View {
|
||||
if authorized {
|
||||
migrateView()
|
||||
} else {
|
||||
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
|
||||
.onAppear(perform: runAuth)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
private func runAuth() { authorize(NSLocalizedString("Open migration to another device", comment: "authentication reason"), $authorized) }
|
||||
|
||||
func migrateView() -> some View {
|
||||
VStack {
|
||||
switch migrationState {
|
||||
case .initial: EmptyView()
|
||||
@ -52,17 +84,19 @@ struct MigrateToAnotherDevice: View {
|
||||
case .passphraseNotSet:
|
||||
passphraseNotSetView()
|
||||
case .passphraseConfirmation:
|
||||
PassphraseConfirmationView(migrationState: $migrationState, alert: $confirmPassphraseAlert)
|
||||
PassphraseConfirmationView(migrationState: $migrationState, alert: $alert)
|
||||
case .uploadConfirmation:
|
||||
uploadConfirmationView()
|
||||
case .archiving:
|
||||
archivingView()
|
||||
case let .uploadProgress(uploaded, total, archivePath):
|
||||
uploadProgressView(uploaded, totalBytes: total, archivePath)
|
||||
case let .linkCreation(totalBytes, archivePath):
|
||||
linkCreationView(totalBytes, archivePath)
|
||||
case let .linkShown(link, archivePath):
|
||||
linkView(link, archivePath)
|
||||
case let .linkCreation(totalBytes, fileId, archivePath):
|
||||
linkCreationView(totalBytes, fileId, archivePath)
|
||||
case let .linkShown(fileId, link, archivePath):
|
||||
linkView(fileId, link, archivePath)
|
||||
case .finished:
|
||||
finishedView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@ -76,7 +110,7 @@ struct MigrateToAnotherDevice: View {
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if case .linkShown = migrationState {} else if !chatWasStoppedInitially {
|
||||
if case .linkShown = migrationState {} else if case .finished = migrationState {} else if !chatWasStoppedInitially {
|
||||
Task {
|
||||
try? startChat(refreshInvitations: true)
|
||||
}
|
||||
@ -84,12 +118,24 @@ struct MigrateToAnotherDevice: View {
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.alert(item: $confirmPassphraseAlert) { alert in
|
||||
switch alert {
|
||||
case let .startChat(title, text):
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
message: Text(text),
|
||||
primaryButton: .destructive(Text("Start chat")) {
|
||||
startChatAndDismiss()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .deleteChat(title, text):
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
message: Text(text),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
deleteChatAndDismiss()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .wrongPassphrase(title, message):
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
case let .invalidConfirmation(title):
|
||||
@ -121,14 +167,15 @@ struct MigrateToAnotherDevice: View {
|
||||
Section {
|
||||
Text(reason)
|
||||
Button(action: stopChat) {
|
||||
settingsRow("stop.fill", color: .red) {
|
||||
Text("Stop chat")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,8 +193,8 @@ struct MigrateToAnotherDevice: View {
|
||||
Section {
|
||||
Text("All your contacts, conversations and files will be archived and uploaded as encrypted file to configured XFTP relays")
|
||||
Button(action: { migrationState = .archiving }) {
|
||||
settingsRow("tray.and.arrow.up", color: .secondary) {
|
||||
Text("Archive and upload")
|
||||
settingsRow("tray.and.arrow.up") {
|
||||
Text("Archive and upload").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@ -178,35 +225,73 @@ struct MigrateToAnotherDevice: View {
|
||||
}
|
||||
}
|
||||
let ratio = Float(uploadedBytes) / Float(totalBytes)
|
||||
largeProgressView(ratio, "\(Int(ratio * 100))%\n\(ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .binary)) uploaded")
|
||||
largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded")
|
||||
}
|
||||
.onAppear {
|
||||
startUploading(totalBytes, archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkCreationView(_ totalBytes: Int64, _ archivePath: URL) -> some View {
|
||||
private func linkCreationView(_ totalBytes: Int64, _ fileId: Int64, _ archivePath: URL) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Creating archive link…")
|
||||
}
|
||||
}
|
||||
largeProgressView(1, "\(ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .binary))")
|
||||
largeProgressView(1, "100%", "\(ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .binary)) uploaded")
|
||||
}
|
||||
.onAppear {
|
||||
createLink(archivePath)
|
||||
createLink(fileId, archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkView(_ link: String, _ archivePath: URL) -> some View {
|
||||
private func linkView(_ fileId: Int64, _ link: String, _ archivePath: URL) -> some View {
|
||||
List {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: link)
|
||||
Text("Choose Migrate from another device on your new device and scan QR code")
|
||||
HStack {
|
||||
SimpleXLinkQRCode(uri: link)
|
||||
.frame(maxWidth: 200, maxHeight: 200)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
shareLinkButton(link)
|
||||
} header: {
|
||||
Text("Link to uploaded archive")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: { cancelMigration(fileId) }) {
|
||||
settingsRow("multiply") {
|
||||
Text("Cancel migration").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
Button(action: { finishMigration(fileId) }) {
|
||||
settingsRow("checkmark") {
|
||||
Text("Finish migration").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishedView() -> some View {
|
||||
List {
|
||||
Section {
|
||||
Text("You should not use the same database on two devices")
|
||||
Button(action: showDeleteChatAlert) {
|
||||
settingsRow("trash.fill") {
|
||||
Text("Delete database from this device").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
Button(action: showStartChatAlert) {
|
||||
settingsRow("play.fill") {
|
||||
Text("Start chat").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Migration complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,15 +299,18 @@ struct MigrateToAnotherDevice: View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(link)])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
private func largeProgressView(_ value: Float, _ text: LocalizedStringKey) -> some View {
|
||||
private func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View {
|
||||
ZStack {
|
||||
Text(text)
|
||||
.font(.title3)
|
||||
.multilineTextAlignment(.center)
|
||||
VStack {
|
||||
Text(title)
|
||||
.font(.title)
|
||||
Text(description)
|
||||
.font(.title3)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(value))
|
||||
@ -230,6 +318,7 @@ struct MigrateToAnotherDevice: View {
|
||||
Color.accentColor,
|
||||
style: StrokeStyle(lineWidth: 5)
|
||||
)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear, value: value)
|
||||
.frame(width: 250, height: 250)
|
||||
@ -277,10 +366,11 @@ struct MigrateToAnotherDevice: View {
|
||||
for i in 1...20 {
|
||||
try? await Task.sleep(nanoseconds: 50_000000)
|
||||
await MainActor.run {
|
||||
migrationState = .uploadProgress(uploadedBytes: Int64(100_000 * i), totalBytes: 2_000_000, archivePath: archivePath)
|
||||
migrationState = .uploadProgress(uploadedBytes: Int64(Int(totalBytes) * i / 20), totalBytes: totalBytes, archivePath: archivePath)
|
||||
if i == 20 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .linkCreation(totalBytes: totalBytes, archivePath: archivePath)
|
||||
let fileId = Int64(1)
|
||||
migrationState = .linkCreation(totalBytes: totalBytes, fileId: fileId, archivePath: archivePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -288,8 +378,69 @@ struct MigrateToAnotherDevice: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func createLink(_ archivePath: URL) {
|
||||
migrationState = .linkShown(link: "https://simplex.chat", archivePath: archivePath)
|
||||
private func createLink(_ fileId: Int64, _ archivePath: URL) {
|
||||
migrationState = .linkShown(fileId: fileId, link: "https://simplex.chat", archivePath: archivePath)
|
||||
}
|
||||
|
||||
private func cancelUploadedAchive(_ fileId: Int64) {
|
||||
if let user = m.currentUser {
|
||||
Task {
|
||||
await cancelFile(user: user, fileId: fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelMigration(_ fileId: Int64) {
|
||||
cancelUploadedAchive(fileId)
|
||||
if !chatWasStoppedInitially {
|
||||
startChatAndDismiss()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishMigration(_ fileId: Int64) {
|
||||
cancelUploadedAchive(fileId)
|
||||
migrationState = .finished
|
||||
}
|
||||
|
||||
private func showDeleteChatAlert() {
|
||||
alert = .deleteChat()
|
||||
}
|
||||
|
||||
private func showStartChatAlert() {
|
||||
alert = .startChat()
|
||||
}
|
||||
|
||||
private func deleteChatAndDismiss() {
|
||||
Task {
|
||||
do {
|
||||
try await deleteChatAsync()
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
showSettings = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
try initializeChat(start: false)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
}
|
||||
dismiss()
|
||||
} catch let error {
|
||||
alert = .error(title: "Error deleting database", error: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startChatAndDismiss() {
|
||||
Task {
|
||||
try? startChat(refreshInvitations: true)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -298,27 +449,7 @@ private struct PassphraseConfirmationView: View {
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var currentKey: String = ""
|
||||
@State private var verifyingPassphrase: Bool = false
|
||||
@Binding var alert: PassphraseConfirmationViewAlert?
|
||||
|
||||
public enum PassphraseConfirmationViewAlert: Identifiable {
|
||||
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: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@Binding var alert: MigrateToAnotherDeviceViewAlert?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -342,8 +473,8 @@ private struct PassphraseConfirmationView: View {
|
||||
Text("Verify database passphrase to migrate it")
|
||||
} footer: {
|
||||
Text("Make sure you remember database passphrase before migrating")
|
||||
.font(.callout)
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
if verifyingPassphrase {
|
||||
progressView()
|
||||
@ -393,6 +524,6 @@ func chatStoppedView() -> some View {
|
||||
|
||||
struct MigrateToAnotherDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MigrateToAnotherDevice()
|
||||
MigrateToAnotherDevice(showSettings: Binding.constant(true))
|
||||
}
|
||||
}
|
||||
|
@ -207,8 +207,8 @@ struct SettingsView: View {
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
|
||||
NavigationLink {
|
||||
MigrateToAnotherDevice()
|
||||
.navigationTitle("Migrate to another device")
|
||||
MigrateToAnotherDevice(showSettings: $showSettings)
|
||||
.navigationTitle("Migrate device")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("tray.and.arrow.up") { Text("Migrate to another device") }
|
||||
|
@ -36,7 +36,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"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user