// // Copyright 2021 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // import SwiftUI import AVKit struct AuthenticationServerSelectionScreen: View { // MARK: - Properties // MARK: Private @Environment(\.theme) private var theme private let service = ServerDowntimeDefaultService() @State private var showAlertForMissingCameraAuthorization = false @State private var showAlertForInvalidServer = false @State private var isEditingTextField = false @State private var presentQRCodeScanner = false @State private var qrCode = "" // bwi #4976 show maintenance alert @State private var isFetchingDowntime = false @State private var showAlert = false @State private var isInvalidServerAlert = false @State private var activeAlert: ServerMaintenanceAlertType = .showInvalidAppVersionAlert private var textFieldFooterColor: Color { viewModel.viewState.hasValidationError ? theme.colors.alert : theme.colors.tertiaryContent } // MARK: Public @ObservedObject var viewModel: AuthenticationServerSelectionViewModel.Context // MARK: Views var body: some View { GeometryReader { _ in ScrollView { VStack(spacing: 8) { header .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) .padding(.bottom, 16) if BWIBuildSettings.shared.allowScanServerQRCode { scanButton .alert(isPresented: $showAlertForMissingCameraAuthorization) { Alert( title: Text(BWIL10n.authenticationServerSelectionQrMissingAuthorizationTitle), message: Text(BWIL10n.authenticationServerSelectionQrMissingAuthorizationMessage), primaryButton: .default(Text(VectorL10n.settingsTitle), action: openSettingsApp), secondaryButton: .cancel()) } } serverForm .alert(item: $viewModel.alertInfo) { $0.alert } if BWIBuildSettings.shared.authScreenShowTestServerOptions { serverSelectionButton } } .readableFrame() .padding(.horizontal, 16) } } .background(theme.colors.background.ignoresSafeArea()) .toolbar { toolbar } .accentColor(theme.colors.accent) .sheet(isPresented: $presentQRCodeScanner) { AuthenticationServerSelectionQRCodeScanner(qrCode: $qrCode) } .onChange(of: qrCode) { newValue in if !qrCode.isEmpty { viewModel.homeserverAddress = qrCode DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.submit() } } } .alert(isPresented: $showAlert, content: { if isInvalidServerAlert { return self.invalidServerAlert() } else { return service.alert(alertType: activeAlert) { self.submit() } } }) } /// The title, message and icon at the top of the screen. var header: some View { VStack(spacing: 8) { if BWIBuildSettings.shared.bumLoginFlowLayout || BWIBuildSettings.shared.bwiLoginFlowLayout { ServerIcon(image: nil, size: OnboardingMetrics.iconSize) .padding(.bottom, 16) } else { OnboardingIconImage(image: Asset.Images.welcomeExperience1) .padding(.bottom, 16) } Text(viewModel.viewState.headerTitle) .font(theme.fonts.title2B) .multilineTextAlignment(.center) .foregroundColor(theme.colors.primaryContent) .accessibilityIdentifier("headerTitle") Text(viewModel.viewState.headerMessage) .font(theme.fonts.body) .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondaryContent) .accessibilityIdentifier("headerMessage") } } var scanButton: some View { VStack(spacing: 8) { Button { if AVCaptureDevice.authorizationStatus(for: .video) == .denied { showAlertForMissingCameraAuthorization = true } else { qrCode = "" presentQRCodeScanner = true } } label: { Text(viewModel.viewState.scanCodeButtonTitle) } .buttonStyle(PrimaryActionButtonStyle()) .accessibilityIdentifier("qrCodeButton") Text(VectorL10n.or) .foregroundColor(theme.colors.secondaryContent) .padding(5) } } /// The text field and confirm button where the user enters a server URL. var serverForm: some View { VStack(alignment: .leading, spacing: 12) { VStack(spacing: 8) { if #available(iOS 15.0, *) { textField .onSubmit(submit) } else { textField } if let errorMessage = viewModel.viewState.footerErrorMessage { Text(errorMessage) .font(theme.fonts.footnote) .foregroundColor(textFieldFooterColor) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("textFieldFooter") } } Button(action: startButtonAction) { Text(viewModel.viewState.buttonTitle) } .buttonStyle(PrimaryActionButtonStyle()) .disabled(viewModel.viewState.hasValidationError) .accessibilityIdentifier("confirmButton") } } /// The text field, extracted for iOS 15 modifiers to be applied. var textField: some View { TextField(BWIL10n.authenticationServerSelectionServerUrl, text: $viewModel.homeserverAddress) { isEditingTextField = $0 } .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(BorderedInputFieldStyle(isEditing: isEditingTextField, isError: viewModel.viewState.isShowingFooterError)) .onChange(of: viewModel.homeserverAddress) { _ in viewModel.send(viewAction: .clearFooterError) } .accessibilityIdentifier("addressTextField") } @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { if viewModel.viewState.hasModalPresentation { Button { viewModel.send(viewAction: .dismiss) } label: { Text(VectorL10n.cancel) } .accessibilityLabel(VectorL10n.cancel) .accessibilityIdentifier("dismissButton") } } } /// Sends the `confirm` view action so long as the text field input is valid. private func submit() { guard !viewModel.viewState.hasValidationError else { return } if isHomeserverAddressValid(viewModel.homeserverAddress) { viewModel.send(viewAction: .confirm) } else { isInvalidServerAlert = true showAlert = true } } private func isHomeserverAddressValid(_ homeserverAddress: String) -> Bool { if BWIBuildSettings.shared.bwiEnableLoginProtection { let protectionService = LoginProtectionService() protectionService.hashes = BWIBuildSettings.shared.bwiHashes return protectionService.isValid(homeserverAddress) } else { return true } } /// bwi: jump directly into the iOS settings app to allow camera access private func openSettingsApp() { if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } } // bwi: show server selection button var serverSelectionButton: some View { VStack() { Menu(content: { ForEach(ServerURLHelper.shared.serverSettings, id: \.self) { server in Button(server.name, action: { viewModel.homeserverAddress = server.serverUrl }) } }, label: { Button(action: { return }) { Text(BWIL10n.bwiAuthBetaSelectionButtonTitle) } .buttonStyle(PrimaryActionButtonStyle()) }) } } // bwi: show alert with advertizment link if URL is valid private func invalidServerAlert() -> Alert { if let url = URL(string: BWIBuildSettings.shared.bumAdvertizementURLString) { return Alert( title: Text(BWIL10n.authenticationServerSelectionServerDeniedTitle), message: Text(BWIL10n.authenticationServerSelectionServerDeniedMessage), primaryButton: .default(Text(BWIL10n.authenticationServerSelectionServerDeniedAdvertizementWebsiteButton), action: {UIApplication.shared.open(url)}), secondaryButton: .default(Text(VectorL10n.ok))) } else { return Alert( title: Text(BWIL10n.authenticationServerSelectionServerDeniedTitle), message: Text(BWIL10n.authenticationServerSelectionServerDeniedMessage), dismissButton: .default(Text(VectorL10n.ok))) } } // bwi #4295 ask for maintenance before going to login private func startButtonAction() { // #4295: Only ask for maintenance here when there is no server selection afterwards if BWIBuildSettings.shared.enableMaintenanceInfoOnLogin { isFetchingDowntime = true // show progresview if BWIBuildSettings.shared.useTestDataForDowntime { service.fetchDowntimes { self.isFetchingDowntime = false // hide progressview self.showAlertIfNeeded() } } else { service.fetchDowntimesWithDirectRequest(localUrlString:viewModel.homeserverAddress) { success in DispatchQueue.main.async { self.isFetchingDowntime = false // hide progressview if success { self.showAlertIfNeeded() } else { // hotfix 2.9.1 if request ist no successful there probably is no maintenance on server -> don't show popup self.submit() } } } } } else { self.submit() } } private func showAlertIfNeeded() { if service.showAlert() { activeAlert = service.alertType() showAlert = true } else { self.submit() } } } // MARK: - Previews @available(iOS 15.0, *) struct AuthenticationServerSelection_Previews: PreviewProvider { static let stateRenderer = MockAuthenticationServerSelectionScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup(addNavigation: true) .navigationViewStyle(.stack) } }