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.
This commit is contained in:
Doug
2026-04-23 15:52:47 +01:00
committed by GitHub
parent 5e2210f1e8
commit 95c32225a8
12 changed files with 122 additions and 29 deletions
@@ -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
@@ -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
}
@@ -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) {
@@ -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)
@@ -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)
@@ -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):
@@ -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) {
@@ -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,
@@ -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 {
@@ -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]()
@@ -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 {
@@ -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()