diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b69ccbb7c..d7b9fef21 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,11 +14,14 @@ struct ContentView: View { @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared @Environment(\.colorScheme) var colorScheme - @Binding var doAuthenticate: Bool - @Binding var userAuthorized: Bool? - @Binding var canConnectCall: Bool - @Binding var lastSuccessfulUnlock: TimeInterval? - @Binding var showInitializationView: Bool + + var contentAccessAuthenticationExtended: Bool + + @Environment(\.scenePhase) var scenePhase + @State private var automaticAuthenticationAttempted = false + @State private var canConnectViewCall = false + @State private var lastSuccessfulUnlock: TimeInterval? = nil + @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -40,9 +43,19 @@ struct ContentView: View { } } + private var accessAuthenticated: Bool { + chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended + } + var body: some View { ZStack { - contentView() + // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. + // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } + if !prefPerformLA || accessAuthenticated { + contentView() + } else { + lockButton() + } if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } @@ -50,6 +63,7 @@ struct ContentView: View { LocalAuthView(authRequest: la) } else if showSetPasscode { SetAppPasscodeView { + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true showSetPasscode = false privacyLocalAuthModeDefault.set(.passcode) @@ -60,13 +74,9 @@ struct ContentView: View { alertManager.showAlert(laPasscodeNotSetAlert()) } } - } - .onAppear { - if prefPerformLA { requestNtfAuthorization() } - initAuthenticate() - } - .onChange(of: doAuthenticate) { _ in - initAuthenticate() + if chatModel.chatDbStatus == nil { + initializationView() + } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .sheet(isPresented: $showSettings) { @@ -76,14 +86,44 @@ struct ContentView: View { Button("System authentication") { initialEnableLA() } Button("Passcode entry") { showSetPasscode = true } } + .onChange(of: scenePhase) { phase in + logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") + switch (phase) { + case .background: + // also see .onChange(of: scenePhase) in SimpleXApp: on entering background + // it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false + automaticAuthenticationAttempted = false + canConnectViewCall = false + case .active: + canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently() + + // condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice + if prefPerformLA && !chatModel.contentViewAccessAuthenticated { + if AppChatState.shared.value != .stopped { + if contentAccessAuthenticationExtended { + chatModel.contentViewAccessAuthenticated = true + } else { + if !automaticAuthenticationAttempted { + automaticAuthenticationAttempted = true + // authenticate if call kit call is not in progress + if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) { + authenticateContentViewAccess() + } + } + } + } else { + // when app is stopped automatic authentication is not attempted + chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended + } + } + default: + break + } + } } @ViewBuilder private func contentView() -> some View { - if prefPerformLA && userAuthorized != true { - lockButton() - } else if chatModel.chatDbStatus == nil && showInitializationView { - initializationView() - } else if let status = chatModel.chatDbStatus, status != .ok { + if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() @@ -106,11 +146,11 @@ struct ContentView: View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) .onDisappear { - if userAuthorized == false && doAuthenticate { runAuthenticate() } + if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } } } else { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - if prefPerformLA && userAuthorized != true { + ActiveCallView(call: call, canConnectCall: $canConnectViewCall) + if prefPerformLA && !accessAuthenticated { Rectangle() .fill(colorScheme == .dark ? .black : .white) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -120,22 +160,27 @@ struct ContentView: View { } private func lockButton() -> some View { - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } private func initializationView() -> some View { VStack { ProgressView().scaleEffect(2) - Text("Opening database…") + Text("Opening app…") .padding() } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + .background( + Rectangle() + .fill(.background) + ) } private func mainView() -> some View { ZStack(alignment: .top) { ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { - if !prefPerformLA { requestNtfAuthorization() } + requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -187,48 +232,37 @@ struct ContentView: View { } } - private func initAuthenticate() { - logger.debug("initAuthenticate") - if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { - userAuthorized = false - } else if doAuthenticate { - runAuthenticate() - } - } - - private func runAuthenticate() { - logger.debug("DEBUGGING: runAuthenticate") - if !prefPerformLA { - userAuthorized = true + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 } else { - logger.debug("DEBUGGING: before dismissAllSheets") - dismissAllSheets(animated: false) { - logger.debug("DEBUGGING: in dismissAllSheets callback") - chatModel.chatId = nil - justAuthenticate() - } + return false } } - private func justAuthenticate() { - userAuthorized = false - let laMode = privacyLocalAuthModeDefault.get() - authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in - logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") - switch (laResult) { - case .success: - userAuthorized = true - canConnectCall = true - lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime - case .failed: - if laMode == .passcode { - AlertManager.shared.showAlert(laFailedAlert()) + private func authenticateContentViewAccess() { + logger.debug("DEBUGGING: authenticateContentViewAccess") + dismissAllSheets(animated: false) { + logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") + chatModel.chatId = nil + + authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in + logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") + switch (laResult) { + case .success: + chatModel.contentViewAccessAuthenticated = true + canConnectViewCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime + case .failed: + chatModel.contentViewAccessAuthenticated = false + if privacyLocalAuthModeDefault.get() == .passcode { + AlertManager.shared.showAlert(laFailedAlert()) + } + case .unavailable: + prefPerformLA = false + canConnectViewCall = true + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } - case .unavailable: - userAuthorized = true - prefPerformLA = false - canConnectCall = true - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } @@ -259,6 +293,7 @@ struct ContentView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true alertManager.showAlert(laTurnedOnAlert()) case .failed: diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e7932f2d9..0cc281fda 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,6 +54,8 @@ final class ChatModel: ObservableObject { @Published var chatDbChanged = false @Published var chatDbEncrypted: Bool? @Published var chatDbStatus: DBMigrationResult? + // local authentication + @Published var contentViewAccessAuthenticated: Bool = false @Published var laRequest: LocalAuthRequest? // list of chat "previews" @Published var chats: [Chat] = [] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 057188c37..c023f375d 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -16,18 +16,13 @@ struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @ObservedObject var alertManager = AlertManager.shared + @Environment(\.scenePhase) var scenePhase - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false - @State private var userAuthorized: Bool? - @State private var doAuthenticate = false - @State private var enteredBackground: TimeInterval? = nil - @State private var canConnectCall = false - @State private var lastSuccessfulUnlock: TimeInterval? = nil - @State private var showInitializationView = false + @State private var enteredBackgroundAuthenticated: TimeInterval? = nil init() { // DispatchQueue.global(qos: .background).sync { - haskell_init() + haskell_init() // hs_init(0, nil) // } UserDefaults.standard.register(defaults: appDefaults) @@ -39,21 +34,16 @@ struct SimpleXApp: App { } var body: some Scene { - return WindowGroup { - ContentView( - doAuthenticate: $doAuthenticate, - userAuthorized: $userAuthorized, - canConnectCall: $canConnectCall, - lastSuccessfulUnlock: $lastSuccessfulUnlock, - showInitializationView: $showInitializationView - ) + WindowGroup { + // contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, + // so that it's computed by the time view renders, and not on event after rendering + ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url } .onAppear() { - showInitializationView = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } @@ -62,21 +52,25 @@ struct SimpleXApp: App { logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") switch (phase) { case .background: + // --- authentication + // see ContentView .onChange(of: scenePhase) for remaining authentication logic + if chatModel.contentViewAccessAuthenticated { + enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime + } + chatModel.contentViewAccessAuthenticated = false + // authentication --- + if CallController.useCallKit() && chatModel.activeCall != nil { CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() } - if userAuthorized == true { - enteredBackground = ProcessInfo.processInfo.systemUptime - } - doAuthenticate = false - canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false let appState = AppChatState.shared.value + if appState != .stopped { startChatAndActivate { if appState.inactive && chatModel.chatRunning == true { @@ -85,8 +79,6 @@ struct SimpleXApp: App { updateCallInvitations() } } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } } default: @@ -121,22 +113,14 @@ struct SimpleXApp: App { } private func authenticationExpired() -> Bool { - if let enteredBackground = enteredBackground { + if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) - return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay + return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay } else { return true } } - private func unlockedRecently() -> Bool { - if let lastSuccessfulUnlock = lastSuccessfulUnlock { - return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 - } else { - return false - } - } - private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 90b83fa4f..d8ff2c2f8 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -467,6 +467,7 @@ struct SimplexLockView: View { switch a { case .enableAuth: SetAppPasscodeView { + m.contentViewAccessAuthenticated = true laLockDelay = 30 prefPerformLA = true showChangePassword = true @@ -619,6 +620,7 @@ struct SimplexLockView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + m.contentViewAccessAuthenticated = true prefPerformLA = true laAlert = .laTurnedOnAlert case .failed: