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, navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator, appMediator: appMediator,
appSettings: appSettings, appSettings: appSettings,
appHooks: appHooks,
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
coordinator.delegate = self coordinator.delegate = self
@@ -461,7 +461,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.automaticBackPaginationEnabled, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.automaticBackPaginationEnabled, defaultValue: false, storageType: .userDefaults(store))
var automaticBackPaginationEnabled var automaticBackPaginationEnabled
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType == .debug, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType != .release, storageType: .userDefaults(store))
var developerOptionsEnabled var developerOptionsEnabled
} }
@@ -22,6 +22,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator private let navigationStackCoordinator: NavigationStackCoordinator
private let appMediator: AppMediatorProtocol private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let appHooks: AppHooks
private let analytics: AnalyticsService private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
@@ -46,6 +47,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The screen to report an error. /// The screen to report an error.
case bugReportFlow case bugReportFlow
/// The screen to toggle feature flags.
case developerOptions
/// The flow is complete. /// The flow is complete.
case complete case complete
@@ -62,8 +65,6 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case loginWithQR case loginWithQR
/// Show the server confirmation screen. /// Show the server confirmation screen.
case confirmServer(AuthenticationFlow) case confirmServer(AuthenticationFlow)
/// The user encountered a problem.
case reportProblem
/// The QR login flow was aborted. /// The QR login flow was aborted.
case cancelledLoginWithQR case cancelledLoginWithQR
@@ -84,9 +85,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The password login was aborted. /// The password login was aborted.
case cancelledPasswordLogin(previousState: State) case cancelledPasswordLogin(previousState: State)
/// The user encountered a problem.
case reportProblem
/// The user has finished reporting a problem (or viewing the logs). /// The user has finished reporting a problem (or viewing the logs).
case bugReportFlowComplete 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`. /// The user has successfully signed in. The new session can be found in the `userInfo`.
case signedIn case signedIn
} }
@@ -106,6 +114,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
navigationRootCoordinator: NavigationRootCoordinator, navigationRootCoordinator: NavigationRootCoordinator,
appMediator: AppMediatorProtocol, appMediator: AppMediatorProtocol,
appSettings: AppSettings, appSettings: AppSettings,
appHooks: AppHooks,
analytics: AnalyticsService, analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) { userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService self.authenticationService = authenticationService
@@ -113,6 +122,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
self.navigationRootCoordinator = navigationRootCoordinator self.navigationRootCoordinator = navigationRootCoordinator
self.appMediator = appMediator self.appMediator = appMediator
self.appSettings = appSettings self.appSettings = appSettings
self.appHooks = appHooks
self.analytics = analytics self.analytics = analytics
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
@@ -163,6 +173,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.popToRoot(animated: animated) navigationStackCoordinator.popToRoot(animated: animated)
case .bugReportFlow: case .bugReportFlow:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
case .developerOptions:
navigationStackCoordinator.setSheetCoordinator(nil)
case .complete: case .complete:
fatalError() fatalError()
} }
@@ -231,6 +243,13 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
} }
stateMachine.addRoutes(event: .bugReportFlowComplete, transitions: [.bugReportFlow => .startScreen]) 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 // Completion
stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete, stateMachine.addRoutes(event: .signedIn, transitions: [.qrCodeLoginScreen => .complete,
@@ -285,13 +304,16 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.confirmServer(.login)) stateMachine.tryEvent(.confirmServer(.login))
case .register: case .register:
stateMachine.tryEvent(.confirmServer(.register)) stateMachine.tryEvent(.confirmServer(.register))
case .reportProblem:
stateMachine.tryEvent(.reportProblem)
case .loginDirectlyWithOIDC(let oidcData, let window): case .loginDirectlyWithOIDC(let oidcData, let window):
stateMachine.tryEvent(.continueWithOIDC, userInfo: (oidcData, window)) stateMachine.tryEvent(.continueWithOIDC, userInfo: (oidcData, window))
case .loginDirectlyWithPassword(let loginHint): case .loginDirectlyWithPassword(let loginHint):
stateMachine.tryEvent(.continueWithPassword, userInfo: loginHint) stateMachine.tryEvent(.continueWithPassword, userInfo: loginHint)
case .reportProblem:
stateMachine.tryEvent(.reportProblem)
case .developerOptions:
stateMachine.tryEvent(.developerOptions)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -465,6 +487,28 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
coordinator.start() 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 // MARK: - Completion
private func userHasSignedIn(userSession: UserSessionProtocol) { private func userHasSignedIn(userSession: UserSessionProtocol) {
@@ -23,10 +23,12 @@ enum AuthenticationStartScreenCoordinatorAction {
case loginWithQR case loginWithQR
case login case login
case register case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?) case loginDirectlyWithPassword(loginHint: String?)
case reportProblem
case developerOptions
} }
final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
@@ -62,13 +64,16 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.login) actionsSubject.send(.login)
case .register: case .register:
actionsSubject.send(.register) actionsSubject.send(.register)
case .reportProblem:
actionsSubject.send(.reportProblem)
case .loginDirectlyWithOIDC(let data, let window): case .loginDirectlyWithOIDC(let data, let window):
actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window)) actionsSubject.send(.loginDirectlyWithOIDC(data: data, window: window))
case .loginDirectlyWithPassword(let loginHint): case .loginDirectlyWithPassword(let loginHint):
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint)) actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
case .reportProblem:
actionsSubject.send(.reportProblem)
case .developerOptions:
actionsSubject.send(.developerOptions)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -12,10 +12,12 @@ enum AuthenticationStartScreenViewModelAction: Equatable {
case loginWithQR case loginWithQR
case login case login
case register case register
case reportProblem
case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow)
case loginDirectlyWithPassword(loginHint: String?) case loginDirectlyWithPassword(loginHint: String?)
case reportProblem
case developerOptions
} }
struct AuthenticationStartScreenViewState: BindableState { struct AuthenticationStartScreenViewState: BindableState {
@@ -56,11 +58,12 @@ enum AuthenticationStartScreenAlertType {
enum AuthenticationStartScreenViewAction { enum AuthenticationStartScreenViewAction {
/// Updates the window used as the OIDC presentation anchor. /// Updates the window used as the OIDC presentation anchor.
case updateWindow(UIWindow) case updateWindow(UIWindow)
case developerOptions
case reportProblem
case loginWithQR case loginWithQR
case login case login
case register case register
case reportProblem
case continueWithClassic(ClassicAppAccount) case continueWithClassic(ClassicAppAccount)
case otherOptions(ClassicAppAccount) case otherOptions(ClassicAppAccount)
@@ -83,16 +83,20 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
case .updateWindow(let window): case .updateWindow(let window):
guard state.window != window else { return } guard state.window != window else { return }
state.window = window state.window = window
case .reportProblem:
if canReportProblem {
actionsSubject.send(.reportProblem)
}
case .developerOptions:
actionsSubject.send(.developerOptions)
case .loginWithQR: case .loginWithQR:
actionsSubject.send(.loginWithQR) actionsSubject.send(.loginWithQR)
case .login: case .login:
Task { await login() } Task { await login() }
case .register: case .register:
actionsSubject.send(.register) actionsSubject.send(.register)
case .reportProblem:
if canReportProblem {
actionsSubject.send(.reportProblem)
}
case .continueWithClassic(let account): case .continueWithClassic(let account):
Task { await login(classicAppAccount: account) } Task { await login(classicAppAccount: account) }
case .otherOptions(let account): case .otherOptions(let account):
@@ -123,12 +123,15 @@ struct AuthenticationStartScreen: View {
versionText versionText
.font(.compound.bodySM) .font(.compound.bodySM)
.foregroundColor(.compound.textSecondary) .foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity)
.padding(.top, 16)
.onTapGesture(count: 7) { .onTapGesture(count: 7) {
context.send(viewAction: .reportProblem) context.send(viewAction: .reportProblem)
} }
.accessibilityIdentifier(A11yIdentifiers.authenticationStartScreen.appVersion) .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) .padding(.horizontal, verticalSizeClass == .compact ? 128 : 24)
.readableFrame() .readableFrame()
@@ -140,6 +143,17 @@ struct AuthenticationStartScreen: View {
return Text(L10n.screenOnboardingAppVersion(shortVersionString)) 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 @ToolbarContentBuilder
var toolbar: some ToolbarContent { var toolbar: some ToolbarContent {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
@@ -23,7 +23,7 @@ final class DeveloperOptionsScreenCoordinator: CoordinatorProtocol {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(appSettings: AppSettings, appHooks: AppHooks, clientProxy: ClientProxyProtocol) { init(appSettings: AppSettings, appHooks: AppHooks, clientProxy: ClientProxyProtocol?) {
viewModel = DeveloperOptionsScreenViewModel(developerOptions: appSettings, viewModel = DeveloperOptionsScreenViewModel(developerOptions: appSettings,
elementCallBaseURL: appSettings.elementCallBaseURL, elementCallBaseURL: appSettings.elementCallBaseURL,
appHooks: appHooks, appHooks: appHooks,
@@ -16,6 +16,9 @@ struct DeveloperOptionsScreenViewState: BindableState {
let elementCallBaseURL: URL let elementCallBaseURL: URL
let appHooks: AppHooks let appHooks: AppHooks
var storeSizes: [StoreSize]? var storeSizes: [StoreSize]?
let shouldShowClearCache: Bool
let isPresentedModally: Bool
var bindings: DeveloperOptionsScreenViewStateBindings var bindings: DeveloperOptionsScreenViewStateBindings
struct StoreSize: Identifiable { struct StoreSize: Identifiable {
@@ -18,13 +18,15 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
actionsSubject.eraseToAnyPublisher() 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, super.init(initialViewState: .init(elementCallBaseURL: elementCallBaseURL,
appHooks: appHooks, appHooks: appHooks,
shouldShowClearCache: clientProxy != nil,
isPresentedModally: clientProxy == nil,
bindings: .init(developerOptions: developerOptions))) bindings: .init(developerOptions: developerOptions)))
Task { Task {
if case let .success(sizes) = await clientProxy.storeSizes() { if case let .success(sizes) = await clientProxy?.storeSizes() {
let formatter = ByteCountFormatStyle(style: .file) let formatter = ByteCountFormatStyle(style: .file)
var components = [DeveloperOptionsScreenViewState.StoreSize]() var components = [DeveloperOptionsScreenViewState.StoreSize]()
@@ -9,6 +9,8 @@
import SwiftUI import SwiftUI
struct DeveloperOptionsScreen: View { struct DeveloperOptionsScreen: View {
@Environment(\.dismiss) private var dismiss
@Bindable var context: DeveloperOptionsScreenViewModel.Context @Bindable var context: DeveloperOptionsScreenViewModel.Context
@State private var showConfetti = false @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.") 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) TextField("Leave empty to use EC locally", text: $elementCallURLOverrideString)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.autocapitalization(.none) .autocapitalization(.none)
@@ -134,8 +136,6 @@ struct DeveloperOptionsScreen: View {
context.elementCallBaseURLOverride = url context.elementCallBaseURLOverride = url
} }
} }
} header: {
Text("Element Call remote URL override")
} }
Section("Notifications") { Section("Notifications") {
@@ -158,19 +158,22 @@ struct DeveloperOptionsScreen: View {
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 } // Fix separator alignment .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } // Fix separator alignment
} }
} }
Section { if context.viewState.shouldShowClearCache {
Button(role: .destructive) { Section {
context.send(viewAction: .clearCache) Button(role: .destructive) {
} label: { context.send(viewAction: .clearCache)
Text("Clear cache") } label: {
.frame(maxWidth: .infinity) Text("Clear cache")
.frame(maxWidth: .infinity)
}
} }
} }
} }
.overlay(effectsView) .overlay(effectsView)
.navigationTitle(L10n.commonDeveloperOptions) .navigationTitle(L10n.commonDeveloperOptions)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
} }
@ViewBuilder @ViewBuilder
@@ -187,6 +190,19 @@ struct DeveloperOptionsScreen: View {
try? await Task.sleep(for: .seconds(4)) try? await Task.sleep(for: .seconds(4))
showConfetti = false 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 { private struct LogLevelConfigurationView: View {
@@ -157,6 +157,7 @@ class MockScreen: Identifiable {
navigationRootCoordinator: navigationRootCoordinator, navigationRootCoordinator: navigationRootCoordinator,
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: appSettings, appSettings: appSettings,
appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
flowCoordinator.start() flowCoordinator.start()