ios: rework authentication (#3556)
This commit is contained in:
parent
f0338a03d1
commit
ce9218b186
@ -14,11 +14,14 @@ struct ContentView: View {
|
|||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var callController = CallController.shared
|
@ObservedObject var callController = CallController.shared
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Binding var doAuthenticate: Bool
|
|
||||||
@Binding var userAuthorized: Bool?
|
var contentAccessAuthenticationExtended: Bool
|
||||||
@Binding var canConnectCall: Bool
|
|
||||||
@Binding var lastSuccessfulUnlock: TimeInterval?
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@Binding var showInitializationView: Bool
|
@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_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = 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 {
|
var body: some View {
|
||||||
ZStack {
|
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 {
|
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||||
callView(call)
|
callView(call)
|
||||||
}
|
}
|
||||||
@ -50,6 +63,7 @@ struct ContentView: View {
|
|||||||
LocalAuthView(authRequest: la)
|
LocalAuthView(authRequest: la)
|
||||||
} else if showSetPasscode {
|
} else if showSetPasscode {
|
||||||
SetAppPasscodeView {
|
SetAppPasscodeView {
|
||||||
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
showSetPasscode = false
|
showSetPasscode = false
|
||||||
privacyLocalAuthModeDefault.set(.passcode)
|
privacyLocalAuthModeDefault.set(.passcode)
|
||||||
@ -60,13 +74,9 @@ struct ContentView: View {
|
|||||||
alertManager.showAlert(laPasscodeNotSetAlert())
|
alertManager.showAlert(laPasscodeNotSetAlert())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if chatModel.chatDbStatus == nil {
|
||||||
.onAppear {
|
initializationView()
|
||||||
if prefPerformLA { requestNtfAuthorization() }
|
}
|
||||||
initAuthenticate()
|
|
||||||
}
|
|
||||||
.onChange(of: doAuthenticate) { _ in
|
|
||||||
initAuthenticate()
|
|
||||||
}
|
}
|
||||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
@ -76,14 +86,44 @@ struct ContentView: View {
|
|||||||
Button("System authentication") { initialEnableLA() }
|
Button("System authentication") { initialEnableLA() }
|
||||||
Button("Passcode entry") { showSetPasscode = true }
|
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 {
|
@ViewBuilder private func contentView() -> some View {
|
||||||
if prefPerformLA && userAuthorized != true {
|
if let status = chatModel.chatDbStatus, status != .ok {
|
||||||
lockButton()
|
|
||||||
} else if chatModel.chatDbStatus == nil && showInitializationView {
|
|
||||||
initializationView()
|
|
||||||
} else if let status = chatModel.chatDbStatus, status != .ok {
|
|
||||||
DatabaseErrorView(status: status)
|
DatabaseErrorView(status: status)
|
||||||
} else if !chatModel.v3DBMigration.startChat {
|
} else if !chatModel.v3DBMigration.startChat {
|
||||||
MigrateToAppGroupView()
|
MigrateToAppGroupView()
|
||||||
@ -106,11 +146,11 @@ struct ContentView: View {
|
|||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if userAuthorized == false && doAuthenticate { runAuthenticate() }
|
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActiveCallView(call: call, canConnectCall: $canConnectCall)
|
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
|
||||||
if prefPerformLA && userAuthorized != true {
|
if prefPerformLA && !accessAuthenticated {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(colorScheme == .dark ? .black : .white)
|
.fill(colorScheme == .dark ? .black : .white)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@ -120,22 +160,27 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func lockButton() -> some 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 {
|
private func initializationView() -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProgressView().scaleEffect(2)
|
ProgressView().scaleEffect(2)
|
||||||
Text("Opening database…")
|
Text("Opening app…")
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||||
|
.background(
|
||||||
|
Rectangle()
|
||||||
|
.fill(.background)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainView() -> some View {
|
private func mainView() -> some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !prefPerformLA { requestNtfAuthorization() }
|
requestNtfAuthorization()
|
||||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||||
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
|
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
|
||||||
prefLANoticeShown = true
|
prefLANoticeShown = true
|
||||||
@ -187,48 +232,37 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initAuthenticate() {
|
private func unlockedRecently() -> Bool {
|
||||||
logger.debug("initAuthenticate")
|
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||||
userAuthorized = false
|
|
||||||
} else if doAuthenticate {
|
|
||||||
runAuthenticate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runAuthenticate() {
|
|
||||||
logger.debug("DEBUGGING: runAuthenticate")
|
|
||||||
if !prefPerformLA {
|
|
||||||
userAuthorized = true
|
|
||||||
} else {
|
} else {
|
||||||
logger.debug("DEBUGGING: before dismissAllSheets")
|
return false
|
||||||
dismissAllSheets(animated: false) {
|
|
||||||
logger.debug("DEBUGGING: in dismissAllSheets callback")
|
|
||||||
chatModel.chatId = nil
|
|
||||||
justAuthenticate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func justAuthenticate() {
|
private func authenticateContentViewAccess() {
|
||||||
userAuthorized = false
|
logger.debug("DEBUGGING: authenticateContentViewAccess")
|
||||||
let laMode = privacyLocalAuthModeDefault.get()
|
dismissAllSheets(animated: false) {
|
||||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
|
||||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
chatModel.chatId = nil
|
||||||
switch (laResult) {
|
|
||||||
case .success:
|
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||||
userAuthorized = true
|
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||||
canConnectCall = true
|
switch (laResult) {
|
||||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
case .success:
|
||||||
case .failed:
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
if laMode == .passcode {
|
canConnectViewCall = true
|
||||||
AlertManager.shared.showAlert(laFailedAlert())
|
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
|
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||||
switch laResult {
|
switch laResult {
|
||||||
case .success:
|
case .success:
|
||||||
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
alertManager.showAlert(laTurnedOnAlert())
|
alertManager.showAlert(laTurnedOnAlert())
|
||||||
case .failed:
|
case .failed:
|
||||||
|
@ -54,6 +54,8 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var chatDbChanged = false
|
@Published var chatDbChanged = false
|
||||||
@Published var chatDbEncrypted: Bool?
|
@Published var chatDbEncrypted: Bool?
|
||||||
@Published var chatDbStatus: DBMigrationResult?
|
@Published var chatDbStatus: DBMigrationResult?
|
||||||
|
// local authentication
|
||||||
|
@Published var contentViewAccessAuthenticated: Bool = false
|
||||||
@Published var laRequest: LocalAuthRequest?
|
@Published var laRequest: LocalAuthRequest?
|
||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published var chats: [Chat] = []
|
||||||
|
@ -16,18 +16,13 @@ struct SimpleXApp: App {
|
|||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@StateObject private var chatModel = ChatModel.shared
|
@StateObject private var chatModel = ChatModel.shared
|
||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
|
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
|
||||||
@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
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// DispatchQueue.global(qos: .background).sync {
|
// DispatchQueue.global(qos: .background).sync {
|
||||||
haskell_init()
|
haskell_init()
|
||||||
// hs_init(0, nil)
|
// hs_init(0, nil)
|
||||||
// }
|
// }
|
||||||
UserDefaults.standard.register(defaults: appDefaults)
|
UserDefaults.standard.register(defaults: appDefaults)
|
||||||
@ -39,21 +34,16 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
return WindowGroup {
|
WindowGroup {
|
||||||
ContentView(
|
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
|
||||||
doAuthenticate: $doAuthenticate,
|
// so that it's computed by the time view renders, and not on event after rendering
|
||||||
userAuthorized: $userAuthorized,
|
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||||
canConnectCall: $canConnectCall,
|
|
||||||
lastSuccessfulUnlock: $lastSuccessfulUnlock,
|
|
||||||
showInitializationView: $showInitializationView
|
|
||||||
)
|
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
}
|
}
|
||||||
.onAppear() {
|
.onAppear() {
|
||||||
showInitializationView = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
initChatAndMigrate()
|
initChatAndMigrate()
|
||||||
}
|
}
|
||||||
@ -62,21 +52,25 @@ struct SimpleXApp: App {
|
|||||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case .background:
|
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 {
|
if CallController.useCallKit() && chatModel.activeCall != nil {
|
||||||
CallController.shared.shouldSuspendChat = true
|
CallController.shared.shouldSuspendChat = true
|
||||||
} else {
|
} else {
|
||||||
suspendChat()
|
suspendChat()
|
||||||
BGManager.shared.schedule()
|
BGManager.shared.schedule()
|
||||||
}
|
}
|
||||||
if userAuthorized == true {
|
|
||||||
enteredBackground = ProcessInfo.processInfo.systemUptime
|
|
||||||
}
|
|
||||||
doAuthenticate = false
|
|
||||||
canConnectCall = false
|
|
||||||
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
||||||
case .active:
|
case .active:
|
||||||
CallController.shared.shouldSuspendChat = false
|
CallController.shared.shouldSuspendChat = false
|
||||||
let appState = AppChatState.shared.value
|
let appState = AppChatState.shared.value
|
||||||
|
|
||||||
if appState != .stopped {
|
if appState != .stopped {
|
||||||
startChatAndActivate {
|
startChatAndActivate {
|
||||||
if appState.inactive && chatModel.chatRunning == true {
|
if appState.inactive && chatModel.chatRunning == true {
|
||||||
@ -85,8 +79,6 @@ struct SimpleXApp: App {
|
|||||||
updateCallInvitations()
|
updateCallInvitations()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doAuthenticate = authenticationExpired()
|
|
||||||
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@ -121,22 +113,14 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func authenticationExpired() -> Bool {
|
private func authenticationExpired() -> Bool {
|
||||||
if let enteredBackground = enteredBackground {
|
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
|
||||||
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
||||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
|
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unlockedRecently() -> Bool {
|
|
||||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
|
||||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateChats() {
|
private func updateChats() {
|
||||||
do {
|
do {
|
||||||
let chats = try apiGetChats()
|
let chats = try apiGetChats()
|
||||||
|
@ -467,6 +467,7 @@ struct SimplexLockView: View {
|
|||||||
switch a {
|
switch a {
|
||||||
case .enableAuth:
|
case .enableAuth:
|
||||||
SetAppPasscodeView {
|
SetAppPasscodeView {
|
||||||
|
m.contentViewAccessAuthenticated = true
|
||||||
laLockDelay = 30
|
laLockDelay = 30
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
showChangePassword = true
|
showChangePassword = true
|
||||||
@ -619,6 +620,7 @@ struct SimplexLockView: View {
|
|||||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||||
switch laResult {
|
switch laResult {
|
||||||
case .success:
|
case .success:
|
||||||
|
m.contentViewAccessAuthenticated = true
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
laAlert = .laTurnedOnAlert
|
laAlert = .laTurnedOnAlert
|
||||||
case .failed:
|
case .failed:
|
||||||
|
Loading…
Reference in New Issue
Block a user