From 95c32225a8b9026932aa0520399d10cd3dc967ba Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:52:47 +0100 Subject: [PATCH] Add a Developer Options button to the AuthenticationStartScreen on Nightly builds. (#5472) * Add a Developer Options button to the AuthenticationStartScreen on Nightly builds. * Make sure the Developer Options are actually shown in the settings screen on Nightlies. --- .../Sources/Application/AppCoordinator.swift | 1 + .../Application/Settings/AppSettings.swift | 2 +- .../AuthenticationFlowCoordinator.swift | 52 +++++++++++++++++-- ...AuthenticationStartScreenCoordinator.swift | 11 ++-- .../AuthenticationStartScreenModels.swift | 7 ++- .../AuthenticationStartScreenViewModel.swift | 12 +++-- .../View/AuthenticationStartScreen.swift | 18 ++++++- .../DeveloperOptionsScreenCoordinator.swift | 2 +- .../DeveloperOptionsScreenModels.swift | 3 ++ .../DeveloperOptionsScreenViewModel.swift | 6 ++- .../View/DeveloperOptionsScreen.swift | 36 +++++++++---- .../UITests/UITestsAppCoordinator.swift | 1 + 12 files changed, 122 insertions(+), 29 deletions(-) diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index d660d847d..3ca84db64 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -662,6 +662,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg navigationRootCoordinator: navigationRootCoordinator, appMediator: appMediator, appSettings: appSettings, + appHooks: appHooks, analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) coordinator.delegate = self diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index d67c6762a..3baff8111 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -461,7 +461,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.automaticBackPaginationEnabled, defaultValue: false, storageType: .userDefaults(store)) var automaticBackPaginationEnabled - @UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType == .debug, storageType: .userDefaults(store)) + @UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType != .release, storageType: .userDefaults(store)) var developerOptionsEnabled } diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index f6bffff9c..527cd10e5 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -22,6 +22,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { private let navigationStackCoordinator: NavigationStackCoordinator private let appMediator: AppMediatorProtocol private let appSettings: AppSettings + private let appHooks: AppHooks private let analytics: AnalyticsService private let userIndicatorController: UserIndicatorControllerProtocol @@ -46,6 +47,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { /// The screen to report an error. case bugReportFlow + /// The screen to toggle feature flags. + case developerOptions /// The flow is complete. case complete @@ -62,8 +65,6 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { case loginWithQR /// Show the server confirmation screen. case confirmServer(AuthenticationFlow) - /// The user encountered a problem. - case reportProblem /// The QR login flow was aborted. case cancelledLoginWithQR @@ -84,9 +85,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { /// The password login was aborted. case cancelledPasswordLogin(previousState: State) + /// The user encountered a problem. + case reportProblem /// The user has finished reporting a problem (or viewing the logs). case bugReportFlowComplete + /// The user wants to toggle a feature flag. + case developerOptions + /// The user finished toggling feature flags. + case dismissedDeveloperOptions + /// The user has successfully signed in. The new session can be found in the `userInfo`. case signedIn } @@ -106,6 +114,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { navigationRootCoordinator: NavigationRootCoordinator, appMediator: AppMediatorProtocol, appSettings: AppSettings, + appHooks: AppHooks, analytics: AnalyticsService, userIndicatorController: UserIndicatorControllerProtocol) { self.authenticationService = authenticationService @@ -113,6 +122,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { self.navigationRootCoordinator = navigationRootCoordinator self.appMediator = appMediator self.appSettings = appSettings + self.appHooks = appHooks self.analytics = analytics self.userIndicatorController = userIndicatorController @@ -163,6 +173,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { navigationStackCoordinator.popToRoot(animated: animated) case .bugReportFlow: navigationStackCoordinator.setSheetCoordinator(nil) + case .developerOptions: + navigationStackCoordinator.setSheetCoordinator(nil) case .complete: fatalError() } @@ -231,6 +243,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { } stateMachine.addRoutes(event: .bugReportFlowComplete, transitions: [.bugReportFlow => .startScreen]) + // Developer Options + + stateMachine.addRoutes(event: .developerOptions, transitions: [.startScreen => .developerOptions]) { [weak self] _ in + self?.showDeveloperOptionsScreen() + } + stateMachine.addRoutes(event: .dismissedDeveloperOptions, transitions: [.developerOptions => .startScreen]) + // Completion stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete, @@ -285,13 +304,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.confirmServer(.login)) case .register: stateMachine.tryEvent(.confirmServer(.register)) - case .reportProblem: - stateMachine.tryEvent(.reportProblem) case .loginDirectlyWithOIDC(let oidcData, let window): stateMachine.tryEvent(.continueWithOIDC, userInfo: (oidcData, window)) case .loginDirectlyWithPassword(let loginHint): stateMachine.tryEvent(.continueWithPassword, userInfo: loginHint) + + case .reportProblem: + stateMachine.tryEvent(.reportProblem) + case .developerOptions: + stateMachine.tryEvent(.developerOptions) } } .store(in: &cancellables) @@ -465,6 +487,28 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { coordinator.start() } + // MARK: - Developer Options + + private func showDeveloperOptionsScreen() { + let stackCoordinator = NavigationStackCoordinator() + let coordinator = DeveloperOptionsScreenCoordinator(appSettings: appSettings, + appHooks: appHooks, + clientProxy: nil) + coordinator.actions + .sink { action in + switch action { + case .clearCache: + break // Not sent when clientProxy == nil + } + } + .store(in: &cancellables) + + stackCoordinator.setRootCoordinator(coordinator) + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedDeveloperOptions) + } + } + // MARK: - Completion private func userHasSignedIn(userSession: UserSessionProtocol) { diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift index eead1c68e..92d09ebcd 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift @@ -23,10 +23,12 @@ enum AuthenticationStartScreenCoordinatorAction { case loginWithQR case login case register - case reportProblem case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) case loginDirectlyWithPassword(loginHint: String?) + + case reportProblem + case developerOptions } final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { @@ -62,13 +64,16 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.login) case .register: actionsSubject.send(.register) - case .reportProblem: - actionsSubject.send(.reportProblem) case .loginDirectlyWithOIDC(let data, let window): actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window)) case .loginDirectlyWithPassword(let loginHint): actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint)) + + case .reportProblem: + actionsSubject.send(.reportProblem) + case .developerOptions: + actionsSubject.send(.developerOptions) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift index f7ad98170..a3d3d7461 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift @@ -12,10 +12,12 @@ enum AuthenticationStartScreenViewModelAction: Equatable { case loginWithQR case login case register - case reportProblem case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) case loginDirectlyWithPassword(loginHint: String?) + + case reportProblem + case developerOptions } struct AuthenticationStartScreenViewState: BindableState { @@ -56,11 +58,12 @@ enum AuthenticationStartScreenAlertType { enum AuthenticationStartScreenViewAction { /// Updates the window used as the OIDC presentation anchor. case updateWindow(UIWindow) + case developerOptions + case reportProblem case loginWithQR case login case register - case reportProblem case continueWithClassic(ClassicAppAccount) case otherOptions(ClassicAppAccount) diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift index 0a08d744b..1cc7e5ae7 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift @@ -83,16 +83,20 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType case .updateWindow(let window): guard state.window != window else { return } state.window = window + case .reportProblem: + if canReportProblem { + actionsSubject.send(.reportProblem) + } + case .developerOptions: + actionsSubject.send(.developerOptions) + case .loginWithQR: actionsSubject.send(.loginWithQR) case .login: Task { await login() } case .register: actionsSubject.send(.register) - case .reportProblem: - if canReportProblem { - actionsSubject.send(.reportProblem) - } + case .continueWithClassic(let account): Task { await login(classicAppAccount: account) } case .otherOptions(let account): diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift index a598e54fa..0cdbdcae5 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift @@ -123,12 +123,15 @@ struct AuthenticationStartScreen: View { versionText .font(.compound.bodySM) .foregroundColor(.compound.textSecondary) - .frame(maxWidth: .infinity) - .padding(.top, 16) .onTapGesture(count: 7) { context.send(viewAction: .reportProblem) } .accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.appVersion) + .overlay(alignment: .trailing) { + developerOptionsButton + .scaledOffset(x: 32, y: -0.5, relativeTo: .compound.bodySM) + } + .padding(.top, 16) } .padding(.horizontal, verticalSizeClass == .compact ? 128 : 24) .readableFrame() @@ -140,6 +143,17 @@ struct AuthenticationStartScreen: View { return Text(L10n.screenOnboardingAppVersion(shortVersionString)) } + @ViewBuilder + var developerOptionsButton: some View { + if AppSettings.appBuildType != .release, !ProcessInfo.isRunningTests { + Button { context.send(viewAction: .developerOptions) } label: { + CompoundIcon(\.code) + .foregroundStyle(.compound.iconSecondary) + } + .accessibilityLabel(L10n.commonDeveloperOptions) + } + } + @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .primaryAction) { diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift index fd98f45c2..e113d3835 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenCoordinator.swift @@ -23,7 +23,7 @@ final class DeveloperOptionsScreenCoordinator: CoordinatorProtocol { actionsSubject.eraseToAnyPublisher() } - init(appSettings: AppSettings, appHooks: AppHooks, clientProxy: ClientProxyProtocol) { + init(appSettings: AppSettings, appHooks: AppHooks, clientProxy: ClientProxyProtocol?) { viewModel = DeveloperOptionsScreenViewModel(developerOptions: appSettings, elementCallBaseURL: appSettings.elementCallBaseURL, appHooks: appHooks, diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index a42046368..c68e11b21 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -16,6 +16,9 @@ struct DeveloperOptionsScreenViewState: BindableState { let elementCallBaseURL: URL let appHooks: AppHooks var storeSizes: [StoreSize]? + let shouldShowClearCache: Bool + let isPresentedModally: Bool + var bindings: DeveloperOptionsScreenViewStateBindings struct StoreSize: Identifiable { diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index b70372334..9ad05242e 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -18,13 +18,15 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve actionsSubject.eraseToAnyPublisher() } - init(developerOptions: DeveloperOptionsProtocol, elementCallBaseURL: URL, appHooks: AppHooks, clientProxy: ClientProxyProtocol) { + init(developerOptions: DeveloperOptionsProtocol, elementCallBaseURL: URL, appHooks: AppHooks, clientProxy: ClientProxyProtocol?) { super.init(initialViewState: .init(elementCallBaseURL: elementCallBaseURL, appHooks: appHooks, + shouldShowClearCache: clientProxy != nil, + isPresentedModally: clientProxy == nil, bindings: .init(developerOptions: developerOptions))) Task { - if case let .success(sizes) = await clientProxy.storeSizes() { + if case let .success(sizes) = await clientProxy?.storeSizes() { let formatter = ByteCountFormatStyle(style: .file) var components = [DeveloperOptionsScreenViewState.StoreSize]() diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index d1067a7df..faa9f0a6a 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -9,6 +9,8 @@ import SwiftUI struct DeveloperOptionsScreen: View { + @Environment(\.dismiss) private var dismiss + @Bindable var context: DeveloperOptionsScreenViewModel.Context @State private var showConfetti = false @@ -121,7 +123,7 @@ struct DeveloperOptionsScreen: View { Text("WARNING: this feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.") } - Section { + Section("Element Call remote URL override") { TextField("Leave empty to use EC locally", text: $elementCallURLOverrideString) .autocorrectionDisabled(true) .autocapitalization(.none) @@ -134,8 +136,6 @@ struct DeveloperOptionsScreen: View { context.elementCallBaseURLOverride = url } } - } header: { - Text("Element Call remote URL override") } Section("Notifications") { @@ -158,19 +158,22 @@ struct DeveloperOptionsScreen: View { .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } // Fix separator alignment } } - - Section { - Button(role: .destructive) { - context.send(viewAction: .clearCache) - } label: { - Text("Clear cache") - .frame(maxWidth: .infinity) + + if context.viewState.shouldShowClearCache { + Section { + Button(role: .destructive) { + context.send(viewAction: .clearCache) + } label: { + Text("Clear cache") + .frame(maxWidth: .infinity) + } } } } .overlay(effectsView) .navigationTitle(L10n.commonDeveloperOptions) .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } } @ViewBuilder @@ -187,6 +190,19 @@ struct DeveloperOptionsScreen: View { try? await Task.sleep(for: .seconds(4)) showConfetti = false } + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + if context.viewState.isPresentedModally { + ToolbarItem(placement: .primaryAction) { + if #available(iOS 26.0, *) { + Button(role: .close, action: dismiss.callAsFunction) + } else { + Button(L10n.actionDone, action: dismiss.callAsFunction) + } + } + } + } } private struct LogLevelConfigurationView: View { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 8efdef55a..51fe5bb43 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -157,6 +157,7 @@ class MockScreen: Identifiable { navigationRootCoordinator: navigationRootCoordinator, appMediator: AppMediatorMock.default, appSettings: appSettings, + appHooks: AppHooks(), analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) flowCoordinator.start()