diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index aeccaf934..61de5fd2d 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -23,7 +23,10 @@ struct ContentView: View { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false + @State private var showSettings = false @State private var showWhatsNew = false + @State private var showChooseLAMode = false + @State private var showSetPasscode = false var body: some View { ZStack { @@ -31,6 +34,20 @@ struct ContentView: View { if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } + if !showSettings, let la = chatModel.laRequest { + LocalAuthView(authRequest: la) + } else if showSetPasscode { + SetAppPasscodeView { + prefPerformLA = true + showSetPasscode = false + privacyLocalAuthModeDefault.set(.passcode) + alertManager.showAlert(laTurnedOnAlert()) + } cancel: { + prefPerformLA = false + showSetPasscode = false + alertManager.showAlert(laPasscodeNotSetAlert()) + } + } } .onAppear { if prefPerformLA { requestNtfAuthorization() } @@ -40,6 +57,13 @@ struct ContentView: View { initAuthenticate() } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } + .sheet(isPresented: $showSettings) { + SettingsView(showSettings: $showSettings) + } + .confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) { + Button("System authentication") { initialEnableLA() } + Button("Passcode entry") { showSetPasscode = true } + } } @ViewBuilder private func contentView() -> some View { @@ -82,7 +106,7 @@ struct ContentView: View { private func mainView() -> some View { ZStack(alignment: .top) { - ChatListView().privacySensitive(protectScreen) + ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { if !prefPerformLA { requestNtfAuthorization() } // Local Authentication notice is to be shown on next start after onboarding is complete @@ -132,6 +156,7 @@ struct ContentView: View { } private func initAuthenticate() { + logger.debug("initAuthenticate") if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { userAuthorized = false } else if doAuthenticate { @@ -152,14 +177,18 @@ struct ContentView: View { private func justAuthenticate() { userAuthorized = false - authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in + let laMode = privacyLocalAuthModeDefault.get() + authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason")) { laResult in + logger.debug("authenticate callback: \(String(describing: laResult))") switch (laResult) { case .success: userAuthorized = true canConnectCall = true lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .failed: - break + if laMode == .passcode { + AlertManager.shared.showAlert(laFailedAlert()) + } case .unavailable: userAuthorized = true prefPerformLA = false @@ -185,25 +214,28 @@ struct ContentView: View { Alert( title: Text("SimpleX Lock"), message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."), - primaryButton: .default(Text("Turn on")) { - authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in - switch laResult { - case .success: - prefPerformLA = true - alertManager.showAlert(laTurnedOnAlert()) - case .failed: - prefPerformLA = false - alertManager.showAlert(laFailedAlert()) - case .unavailable: - prefPerformLA = false - alertManager.showAlert(laUnavailableInstructionAlert()) - } - } - }, + primaryButton: .default(Text("Turn on")) { showChooseLAMode = true }, secondaryButton: .cancel() ) } + private func initialEnableLA () { + privacyLocalAuthModeDefault.set(.system) + authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in + switch laResult { + case .success: + prefPerformLA = true + alertManager.showAlert(laTurnedOnAlert()) + case .failed: + prefPerformLA = false + alertManager.showAlert(laFailedAlert()) + case .unavailable: + prefPerformLA = false + alertManager.showAlert(laUnavailableInstructionAlert()) + } + } + } + func notificationAlert() -> Alert { Alert( title: Text("Notifications are disabled!"), diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 25d38780d..eada346ec 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -21,6 +21,7 @@ final class ChatModel: ObservableObject { @Published var chatDbChanged = false @Published var chatDbEncrypted: Bool? @Published var chatDbStatus: DBMigrationResult? + @Published var laRequest: LocalAuthRequest? // list of chat "previews" @Published var chats: [Chat] = [] // map of connections network statuses, key is agent connection id diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index b93d402a8..5e03b1310 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -106,7 +106,8 @@ struct SimpleXApp: App { private func authenticationExpired() -> Bool { if let enteredBackground = enteredBackground { - return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 + let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) + return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay } else { return true } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 78ed48ed2..8e599b598 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -11,7 +11,7 @@ import SimpleXChat struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - @State private var showSettings = false + @Binding var showSettings: Bool @State private var searchText = "" @State private var showAddChat = false @State var userPickerVisible = false @@ -114,9 +114,6 @@ struct ChatListView: View { } } } - .sheet(isPresented: $showSettings) { - SettingsView(showSettings: $showSettings) - } } private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { @@ -224,9 +221,9 @@ struct ChatListView_Previews: PreviewProvider { ] return Group { - ChatListView() + ChatListView(showSettings: Binding.constant(false)) .environmentObject(chatModel) - ChatListView() + ChatListView(showSettings: Binding.constant(false)) .environmentObject(ChatModel()) } } diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index acc86ff78..90cd17fbb 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -40,7 +40,7 @@ struct DatabaseEncryptionView: View { @State private var progressIndicator = false @State private var useKeychainToggle = storeDBPassphraseGroupDefault.get() @State private var initialRandomDBPassphrase = initialRandomDBPassphraseGroupDefault.get() - @State private var storedKey = getDatabaseKey() != nil + @State private var storedKey = kcDatabasePassword.get() != nil @State private var currentKey = "" @State private var newKey = "" @State private var confirmNewKey = "" @@ -124,7 +124,7 @@ struct DatabaseEncryptionView: View { } } .onAppear { - if initialRandomDBPassphrase { currentKey = getDatabaseKey() ?? "" } + if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" } } .disabled(m.chatRunning != false) .alert(item: $alert) { item in databaseEncryptionAlert(item) } @@ -140,7 +140,7 @@ struct DatabaseEncryptionView: View { encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) if useKeychain { - if setDatabaseKey(newKey) { + if kcDatabasePassword.set(newKey) { await resetFormAfterEncryption(true) await operationEnded(.databaseEncrypted) } else { @@ -184,7 +184,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 removeDatabaseKey() { + if kcDatabasePassword.remove() { logger.debug("passphrase removed from keychain") setUseKeychain(false) storedKey = false diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index cd18b244c..04e377f3a 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -13,7 +13,7 @@ struct DatabaseErrorView: View { @EnvironmentObject var m: ChatModel @State var status: DBMigrationResult @State private var dbKey = "" - @State private var storedDBKey = getDatabaseKey() + @State private var storedDBKey = kcDatabasePassword.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var showRestoreDbButton = false @State private var starting = false @@ -131,7 +131,7 @@ struct DatabaseErrorView: View { } private func saveAndRunChat() { - if setDatabaseKey(dbKey) { + if kcDatabasePassword.set(dbKey) { storeDBPassphraseGroupDefault.set(true) initialRandomDBPassphraseGroupDefault.set(false) } diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d7b02baa6..d950c48df 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -355,7 +355,7 @@ struct DatabaseView: View { do { let config = ArchiveConfig(archivePath: archivePath.path) try await apiImportArchive(config: config) - _ = removeDatabaseKey() + _ = kcDatabasePassword.remove() await operationEnded(.archiveImported) } catch let error { await operationEnded(.error(title: "Error importing chat database", error: responseError(error))) @@ -375,7 +375,7 @@ struct DatabaseView: View { Task { do { try await apiDeleteStorage() - _ = removeDatabaseKey() + _ = kcDatabasePassword.remove() storeDBPassphraseGroupDefault.set(true) await operationEnded(.chatDeleted) appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) diff --git a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift index fea1c9843..e06ae61ac 100644 --- a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift +++ b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift @@ -8,6 +8,7 @@ import SwiftUI import LocalAuthentication +import SimpleXChat enum LAResult { case success @@ -25,7 +26,31 @@ func authorize(_ text: String, _ authorized: Binding) { } } -func authenticate(reason: String, completed: @escaping (LAResult) -> Void) { +struct LocalAuthRequest { + var title: LocalizedStringKey? // if title is null, reason is shown + var reason: String + var password: String + var completed: (LAResult) -> Void + + static var sample = LocalAuthRequest(title: "Enter Passcode", reason: "Authenticate", password: "", completed: { _ in }) +} + +func authenticate(title: LocalizedStringKey? = nil, reason: String, completed: @escaping (LAResult) -> Void) { + logger.debug("authenticate") + switch privacyLocalAuthModeDefault.get() { + case .system: systemAuthenticate(reason, completed) + case .passcode: + if let password = kcAppPassword.get() { + DispatchQueue.main.async { + ChatModel.shared.laRequest = LocalAuthRequest(title: title, reason: reason, password: password, completed: completed) + } + } else { + completed(.unavailable(authError: NSLocalizedString("No app password", comment: "Authentication unavailable"))) + } + } +} + +func systemAuthenticate(_ reason: String, _ completed: @escaping (LAResult) -> Void) { let laContext = LAContext() var authAvailabilityError: NSError? if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) { @@ -52,6 +77,13 @@ func laTurnedOnAlert() -> Alert { ) } +func laPasscodeNotSetAlert() -> Alert { + mkAlert( + title: "SimpleX Lock not enabled!", + message: "You can turn on SimpleX Lock via Settings." + ) +} + func laFailedAlert() -> Alert { mkAlert( title: "Authentication failed", @@ -72,3 +104,4 @@ func laUnavailableTurningOffAlert() -> Alert { message: "Device authentication is disabled. Turning off SimpleX Lock." ) } + diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift new file mode 100644 index 000000000..5cf74bafd --- /dev/null +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -0,0 +1,34 @@ +// +// LocalAuthView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/04/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct LocalAuthView: View { + @EnvironmentObject var m: ChatModel + var authRequest: LocalAuthRequest + @State private var password = "" + + var body: some View { + PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit") { + let r: LAResult = password == authRequest.password + ? .success + : .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry")) + m.laRequest = nil + authRequest.completed(r) + } cancel: { + m.laRequest = nil + authRequest.completed(.failed(authError: NSLocalizedString("Authentication cancelled", comment: "PIN entry"))) + } + } +} + +struct LocalAuthView_Previews: PreviewProvider { + static var previews: some View { + LocalAuthView(authRequest: LocalAuthRequest.sample) + } +} diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift new file mode 100644 index 000000000..46ce66678 --- /dev/null +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -0,0 +1,156 @@ +// +// PasscodeEntry.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/04/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PasscodeEntry: View { + @EnvironmentObject var m: ChatModel + var width: CGFloat + var height: CGFloat + @Binding var password: String + @State private var showPassword = false + + var body: some View { + VStack { + passwordView() + .padding(.bottom, 4) + if width < height * 2 / 3 { + verticalPasswordGrid() + } else { + horizontalPasswordGrid() + } + } + } + + @ViewBuilder private func passwordView() -> some View { + Text( + password == "" + ? " " + : splitPassword() + ) + .font(showPassword ? .title2.monospacedDigit() : .body) + .onTapGesture { + showPassword = !showPassword + } + .frame(height: 30) + } + + private func splitPassword() -> String { + let n = password.count < 8 ? 8 : 4 + return password.enumerated().reduce("") { acc, c in + acc + + (showPassword ? String(c.element) : "●") + + ((c.offset + 1) % n == 0 ? " " : "") + } + } + + private func verticalPasswordGrid() -> some View { + let s = width / 3 + return VStack(spacing: 0) { + digitsRow(s, 1, 2, 3) + Divider() + digitsRow(s, 4, 5, 6) + Divider() + digitsRow(s, 7, 8, 9) + Divider() + HStack(spacing: 0) { + passwordEdit(s, image: "multiply") { + password = "" + } + Divider() + passwordDigit(s, 0) + Divider() + passwordEdit(s, image: "delete.backward") { + if password != "" { password.removeLast() } + } + } + .frame(height: s) + } + .frame(width: width, height: s * 4 * 0.97) + } + + private func horizontalPasswordGrid() -> some View { + let s = height / 5 + return VStack(spacing: 0) { + horizontalDigitsRow(s, 1, 2, 3) { + passwordEdit(s, image: "multiply") { + password = "" + } + } + Divider() + horizontalDigitsRow(s, 4, 5, 6) { + passwordDigit(s, 0) + } + Divider() + horizontalDigitsRow(s, 7, 8, 9) { + passwordEdit(s, image: "delete.backward") { + if password != "" { password.removeLast() } + } + } + } + .frame(width: s * 4, height: s * 3 * 0.97) + } + + private func digitsRow(_ size: CGFloat, _ d1: Int, _ d2: Int, _ d3: Int) -> some View { + HStack(spacing: 0) { + passwordDigit(size, d1) + Divider() + passwordDigit(size, d2) + Divider() + passwordDigit(size, d3) + } + .frame(height: size * 0.97) + } + + private func horizontalDigitsRow(_ size: CGFloat, _ d1: Int, _ d2: Int, _ d3: Int, _ button: @escaping () -> V) -> some View { + HStack(spacing: 0) { + digitsRow(size, d1, d2, d3) + Divider() + button() + } + .frame(height: size * 0.97) + } + + private func passwordDigit(_ size: CGFloat, _ d: Int) -> some View { + let s = String(describing: d) + return passwordButton(size) { + if password.count < 16 { + password = password + s + } + } label: { + Text(s).font(.title) + } + .disabled(password.count >= 16) + } + + private func passwordEdit(_ size: CGFloat, image: String, action: @escaping () -> Void) -> some View { + passwordButton(size, action: action) { + Image(systemName: image) + } + } + + private func passwordButton(_ size: CGFloat, action: @escaping () -> Void, label: () -> V) -> some View { + let h = size * 0.97 + return Button(action: action) { + ZStack { + Circle() + .frame(width: h, height: h) + .foregroundColor(Color(uiColor: .systemBackground)) + label() + } + } + .foregroundColor(.secondary) + .frame(width: size, height: h) + } +} + +struct PasscodeEntry_Previews: PreviewProvider { + static var previews: some View { + PasscodeEntry(width: 800, height: 420, password: Binding.constant("")) + } +} diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift new file mode 100644 index 000000000..c73ded2d2 --- /dev/null +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift @@ -0,0 +1,92 @@ +// +// PasscodeView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 11/04/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PasscodeView: View { + @Binding var passcode: String + var title: LocalizedStringKey + var reason: String? = nil + var submitLabel: LocalizedStringKey + var submitEnabled: ((String) -> Bool)? + var submit: () -> Void + var cancel: () -> Void + + var body: some View { + GeometryReader { g in + if g.size.width < g.size.height * 2 / 3 { + verticalPasscodeView(g) + } else { + horizontalPasscodeView(g) + } + } + .padding(.horizontal, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground)) + } + + private func verticalPasscodeView(_ g: GeometryProxy) -> some View { + VStack(spacing: 8) { + passcodeEntry(g) + Spacer() + HStack(spacing: 48) { + buttonsView() + } + } + .padding(.vertical, 32) + } + + private func horizontalPasscodeView(_ g: GeometryProxy) -> some View { + HStack(alignment: .bottom, spacing: 48) { + VStack(spacing: 8) { + passcodeEntry(g) + } + VStack(spacing: 48) { + buttonsView() + } + .frame(maxHeight: g.size.height / 5 * 3 * 0.97) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + + @ViewBuilder private func passcodeEntry(_ g: GeometryProxy) -> some View { + Text(title) + .font(.title) + .bold() + .padding(.top, 8) + if let reason = reason { + Text(reason).padding(.top, 4) + } + Spacer() + PasscodeEntry(width: g.size.width, height: g.size.height, password: $passcode) + } + + @ViewBuilder private func buttonsView() -> some View { + Button(action: cancel) { + Label("Cancel", systemImage: "multiply") + } + Button(action: submit) { + Label(submitLabel, systemImage: "checkmark") + } + .disabled(submitEnabled?(passcode) == false || passcode.count < 4) + } +} + +struct PasscodeViewView_Previews: PreviewProvider { + static var previews: some View { + PasscodeView( + passcode: Binding.constant(""), + title: "Enter Passcode", + reason: "Unlock app", + submitLabel: "Submit", + submit: {}, + cancel: {} + ) + } +} diff --git a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift new file mode 100644 index 000000000..5b95ab0cc --- /dev/null +++ b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift @@ -0,0 +1,65 @@ +// +// SetAppPaswordView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/04/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SetAppPasscodeView: View { + var submit: () -> Void + var cancel: () -> Void + @Environment(\.dismiss) var dismiss: DismissAction + @State private var showKeychainError = false + @State private var passcode = "" + @State private var enteredPassword = "" + @State private var confirming = false + + var body: some View { + ZStack { + if confirming { + setPasswordView( + title: "Confirm Passcode", + submitLabel: "Confirm", + submitEnabled: { pwd in pwd == enteredPassword } + ) { + if passcode == enteredPassword { + if kcAppPassword.set(passcode) { + enteredPassword = "" + passcode = "" + dismiss() + submit() + } else { + showKeychainError = true + } + } + } + } else { + setPasswordView(title: "New Passcode", submitLabel: "Save") { + enteredPassword = passcode + passcode = "" + confirming = true + } + } + } + .alert(isPresented: $showKeychainError) { + mkAlert(title: "KeyChain error", message: "Error saving passcode") + } + } + + private func setPasswordView(title: LocalizedStringKey, submitLabel: LocalizedStringKey, submitEnabled: (((String) -> Bool))? = nil, submit: @escaping () -> Void) -> some View { + PasscodeView(passcode: $passcode, title: title, submitLabel: submitLabel, submitEnabled: submitEnabled, submit: submit) { + dismiss() + cancel() + } + } +} + +struct SetAppPasscodeView_Previews: PreviewProvider { + static var previews: some View { + SetAppPasscodeView(submit: {}, cancel: {}) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index a090ca156..596a4e4be 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -38,6 +38,8 @@ struct DeveloperView: View { settingsRow("chevron.left.forwardslash.chevron.right") { Toggle("Show developer options", isOn: $developerTools) } + } header: { + Text("") } footer: { (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 4c0084b68..5be4979f1 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,12 +14,27 @@ struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false + @State private var currentLAMode = privacyLocalAuthModeDefault.get() var body: some View { VStack { List { Section("Device") { - SimplexLockSetting() + NavigationLink { + SimplexLockView(prefPerformLA: $prefPerformLA, currentLAMode: $currentLAMode) + .navigationTitle("SimpleX Lock") + } label: { + if prefPerformLA { + settingsRow("lock.fill", color: .green) { + simplexLockRow(currentLAMode.text) + } + } else { + settingsRow("lock") { + simplexLockRow("Off") + } + } + } settingsRow("eye.slash") { Toggle("Protect app screen", isOn: $protectScreen) } @@ -56,38 +71,125 @@ struct PrivacySettings: View { } } } + + private func simplexLockRow(_ value: LocalizedStringKey) -> some View { + HStack { + Text("SimpleX Lock") + Spacer() + Text(value) + } + } } -struct SimplexLockSetting: View { +enum LAMode: String, Identifiable, CaseIterable { + case system + case passcode + + public var id: Self { self } + + var text: LocalizedStringKey { + switch self { + case .system: return "System" + case .passcode: return "Passcode" + } + } +} + +struct SimplexLockView: View { + @Binding var prefPerformLA: Bool + @Binding var currentLAMode: LAMode + @EnvironmentObject var m: ChatModel @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false + @State private var laMode: LAMode = privacyLocalAuthModeDefault.get() + @AppStorage(DEFAULT_LA_LOCK_DELAY) private var laLockDelay = 30 @State var performLA: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var performLAToggleReset = false - @State var laAlert: laSettingViewAlert? = nil + @State private var performLAModeReset = false + @State private var showPasswordAction: PasswordAction? = nil + @State private var showChangePassword = false + @State var laAlert: LASettingViewAlert? = nil - enum laSettingViewAlert: Identifiable { + enum LASettingViewAlert: Identifiable { case laTurnedOnAlert case laFailedAlert case laUnavailableInstructionAlert case laUnavailableTurningOffAlert + case laPasscodeSetAlert + case laPasscodeChangedAlert + case laPasscodeNotChangedAlert - var id: laSettingViewAlert { get { self } } + var id: Self { self } + } + + enum PasswordAction: Identifiable { + case enableAuth + case toggleMode + case changePassword + + var id: Self { self } + } + + let laDelays: [Int] = [10, 30, 60, 180, 0] + + func laDelayText(_ t: Int) -> LocalizedStringKey { + let m = t / 60 + let s = t % 60 + return t == 0 + ? "Immediately" + : m == 0 || s != 0 + ? "\(s) seconds" // there are no options where both minutes and seconds are needed + : "\(m) minutes" } var body: some View { - settingsRow("lock") { - Toggle("SimpleX Lock", isOn: $performLA) + VStack { + List { + Section("") { + Toggle("Enable lock", isOn: $performLA) + Picker("Lock mode", selection: $laMode) { + ForEach(LAMode.allCases) { mode in + Text(mode.text) + } + } + if performLA { + Picker("Lock after", selection: $laLockDelay) { + let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays + ForEach(delays, id: \.self) { t in + Text(laDelayText(t)) + } + } + if showChangePassword && laMode == .passcode { + Button("Change Passcode") { + changeLAPassword() + } + } + } + } + } } .onChange(of: performLA) { performLAToggle in prefLANoticeShown = true if performLAToggleReset { performLAToggleReset = false - } else { - if performLAToggle { + } else if performLAToggle { + switch currentLAMode { + case .system: enableLA() - } else { - disableLA() + case .passcode: + resetLA() + showPasswordAction = .enableAuth } + } else { + disableLA() + } + } + .onChange(of: laMode) { _ in + if performLAModeReset { + performLAModeReset = false + } else if performLA { + toggleLAMode() + } else { + updateLAMode() } } .alert(item: $laAlert) { alertItem in @@ -96,46 +198,125 @@ struct SimplexLockSetting: View { case .laFailedAlert: return laFailedAlert() case .laUnavailableInstructionAlert: return laUnavailableInstructionAlert() case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert() + case .laPasscodeSetAlert: return passcodeAlert("Passcode set!") + case .laPasscodeChangedAlert: return passcodeAlert("Passcode changed!") + case .laPasscodeNotChangedAlert: return mkAlert(title: "Passcode not changed!") } } + .sheet(item: $showPasswordAction) { a in + switch a { + case .enableAuth: + SetAppPasscodeView { + laLockDelay = 30 + prefPerformLA = true + showChangePassword = true + showLAAlert(.laPasscodeSetAlert) + } cancel: { + resetLAEnabled(false) + } + case .toggleMode: + SetAppPasscodeView { + laLockDelay = 30 + updateLAMode() + showChangePassword = true + showLAAlert(.laPasscodeSetAlert) + } cancel: { + revertLAMode() + } + case .changePassword: + SetAppPasscodeView { + showLAAlert(.laPasscodeChangedAlert) + } cancel: { + showLAAlert(.laPasscodeNotChangedAlert) + } + } + } + .onAppear { + showChangePassword = prefPerformLA && currentLAMode == .passcode + } + .onDisappear() { + m.laRequest = nil + } + } + private func showLAAlert(_ a: LASettingViewAlert) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + laAlert = a + } + } + + private func toggleLAMode() { + authenticate(reason: NSLocalizedString("Change lock mode", comment: "authentication reason")) { laResult in + switch laResult { + case .failed: + revertLAMode() + laAlert = .laFailedAlert + case .success: + switch laMode { + case .system: + updateLAMode() + authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in + switch laResult { + case .success: + _ = kcAppPassword.remove() + laAlert = .laTurnedOnAlert + case .failed, .unavailable: + currentLAMode = .passcode + privacyLocalAuthModeDefault.set(.passcode) + revertLAMode() + laAlert = .laFailedAlert + } + } + case .passcode: + showPasswordAction = .toggleMode + } + case .unavailable: + disableUnavailableLA() + } + } + } + + private func changeLAPassword() { + authenticate(title: "Current Passcode", reason: NSLocalizedString("Change passcode", comment: "authentication reason")) { laResult in + switch laResult { + case .failed: laAlert = .laFailedAlert + case .success: showPasswordAction = .changePassword + case .unavailable: disableUnavailableLA() + } + } } private func enableLA() { + resetLA() authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: prefPerformLA = true laAlert = .laTurnedOnAlert case .failed: - prefPerformLA = false - withAnimation() { - performLA = false - } - performLAToggleReset = true + resetLAEnabled(false) laAlert = .laFailedAlert case .unavailable: - prefPerformLA = false - withAnimation() { - performLA = false - } - performLAToggleReset = true - laAlert = .laUnavailableInstructionAlert + disableUnavailableLA() } } } + private func disableUnavailableLA() { + resetLAEnabled(false) + laMode = .system + updateLAMode() + laAlert = .laUnavailableInstructionAlert + } + private func disableLA() { authenticate(reason: NSLocalizedString("Disable SimpleX Lock", comment: "authentication reason")) { laResult in switch (laResult) { case .success: prefPerformLA = false + resetLA() case .failed: - prefPerformLA = true - withAnimation() { - performLA = true - } - performLAToggleReset = true + resetLAEnabled(true) laAlert = .laFailedAlert case .unavailable: prefPerformLA = false @@ -143,6 +324,32 @@ struct SimplexLockSetting: View { } } } + + private func resetLA() { + _ = kcAppPassword.remove() + laLockDelay = 30 + showChangePassword = false + } + + private func resetLAEnabled(_ onOff: Bool) { + prefPerformLA = onOff + performLAToggleReset = true + withAnimation { performLA = onOff } + } + + private func revertLAMode() { + performLAModeReset = true + withAnimation { laMode = currentLAMode } + } + + private func updateLAMode() { + currentLAMode = laMode + privacyLocalAuthModeDefault.set(laMode) + } + + private func passcodeAlert(_ title: LocalizedStringKey) -> Alert { + mkAlert(title: title, message: "Please remember or store it securely - there is no way to recover a lost passcode!") + } } struct PrivacySettings_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index e885fc1f2..a06269183 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -19,6 +19,8 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice" let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown" let DEFAULT_PERFORM_LA = "performLocalAuthentication" +let DEFAULT_LA_MODE = "localAuthenticationMode" +let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay" let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" @@ -48,22 +50,24 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_PERFORM_LA: false, + DEFAULT_LA_MODE: LAMode.system.rawValue, + DEFAULT_LA_LOCK_DELAY: 30, DEFAULT_NOTIFICATION_ALERT_SHOWN: false, DEFAULT_WEBRTC_POLICY_RELAY: true, DEFAULT_CALL_KIT_CALLS_IN_RECENTS: false, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true, - DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: "description", + DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_EXPERIMENTAL_CALLS: false, - DEFAULT_CHAT_V3_DB_MIGRATION: "offer", + DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue, DEFAULT_DEVELOPER_TOOLS: false, DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_ACCENT_COLOR_RED: 0.000, DEFAULT_ACCENT_COLOR_GREEN: 0.533, DEFAULT_ACCENT_COLOR_BLUE: 1.000, DEFAULT_USER_INTERFACE_STYLE: 0, - DEFAULT_CONNECT_VIA_LINK_TAB: "scan", + DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, @@ -99,6 +103,8 @@ let connectViaLinkTabDefault = EnumDefault(defaults: UserDefa let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) +let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) + func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) } @@ -111,8 +117,16 @@ struct SettingsView: View { @State private var settingsSheet: SettingsSheet? var body: some View { - let user: User = chatModel.currentUser! + ZStack { + settingsView() + if let la = chatModel.laRequest { + LocalAuthView(authRequest: la) + } + } + } + @ViewBuilder func settingsView() -> some View { + let user: User = chatModel.currentUser! NavigationView { List { Section("You") { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 83d57ecdb..4e3a1b7dd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -104,6 +104,10 @@ 5CB634A429E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB6349F29E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq.a */; }; 5CB634A529E1EE550066AD6B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB634A029E1EE550066AD6B /* libgmpxx.a */; }; 5CB634A629E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CB634A129E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq-ghc8.10.7.a */; }; + 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB634A729E437960066AD6B /* PasscodeEntry.swift */; }; + 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */; }; + 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */; }; + 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; @@ -360,6 +364,10 @@ 5CB6349F29E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq.a"; sourceTree = ""; }; 5CB634A029E1EE550066AD6B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CB634A129E1EE550066AD6B /* libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.6.1.2-C345n6sAXGM3veVcbT76Lq-ghc8.10.7.a"; sourceTree = ""; }; + 5CB634A729E437960066AD6B /* PasscodeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeEntry.swift; sourceTree = ""; }; + 5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = ""; }; + 5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = ""; }; + 5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = ""; }; @@ -509,6 +517,7 @@ 5CB9250B27A942F300ACCCDD /* ChatList */, 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, + 5CB634AB29E46CDB0066AD6B /* LocalAuth */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -659,6 +668,17 @@ path = Onboarding; sourceTree = ""; }; + 5CB634AB29E46CDB0066AD6B /* LocalAuth */ = { + isa = PBXGroup; + children = ( + 5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */, + 5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */, + 5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */, + 5CB634A729E437960066AD6B /* PasscodeEntry.swift */, + ); + path = LocalAuth; + sourceTree = ""; + }; 5CB924DD27A8622200ACCCDD /* NewChat */ = { isa = PBXGroup; children = ( @@ -1048,11 +1068,13 @@ 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, 5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, + 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, @@ -1094,6 +1116,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, + 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, @@ -1148,6 +1171,7 @@ 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, + 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */, 184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index a400a82fb..c0f4e6148 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -30,7 +30,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio logger.debug("chatMigrateInit generating a random DB key") dbKey = randomDatabasePassword() initialRandomDBPassphraseGroupDefault.set(true) - } else if let key = getDatabaseKey() { + } else if let key = kcDatabasePassword.get() { dbKey = key } } @@ -44,7 +44,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio let cjson = chat_migrate_init(&cPath, &cKey, &cConfirm, &chatController)! let dbRes = dbMigrationResult(fromCString(cjson)) let encrypted = dbKey != "" - let keychainErr = dbRes == .ok && useKeychain && encrypted && !setDatabaseKey(dbKey) + let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) let result = (encrypted, keychainErr ? .errorKeychain : dbRes) migrationResult = result return result diff --git a/apps/ios/SimpleXChat/KeyChain.swift b/apps/ios/SimpleXChat/KeyChain.swift index 704c5f752..91d94146a 100644 --- a/apps/ios/SimpleXChat/KeyChain.swift +++ b/apps/ios/SimpleXChat/KeyChain.swift @@ -12,17 +12,26 @@ import Security private let ACCESS_POLICY: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly private let ACCESS_GROUP: String = "5NN7GUYB6T.chat.simplex.app" private let DATABASE_PASSWORD_ITEM: String = "databasePassword" +private let APP_PASSWORD_ITEM: String = "appPassword" -public func getDatabaseKey() -> String? { - getItemString(forKey: DATABASE_PASSWORD_ITEM) -} +public let kcDatabasePassword = KeyChainItem(forKey: DATABASE_PASSWORD_ITEM) -public func setDatabaseKey(_ key: String) -> Bool { - setItemString(key, forKey: DATABASE_PASSWORD_ITEM) -} +public let kcAppPassword = KeyChainItem(forKey: APP_PASSWORD_ITEM) -public func removeDatabaseKey() -> Bool { - deleteItem(forKey: DATABASE_PASSWORD_ITEM) +public struct KeyChainItem { + var forKey: String + + public func get() -> String? { + getItemString(forKey: forKey) + } + + public func set(_ value: String) -> Bool { + setItemString(value, forKey: forKey) + } + + public func remove() -> Bool { + deleteItem(forKey: forKey) + } } func randomDatabasePassword() -> String {