AppSettings are Mockable (#5589)

* create DependenciesProtocol and replace ServiceLocator registration with it

replace mocking calls for ServiceLocator to Dependencies

replace ServiceLocator with instance level calls

replace ServiceLocator configs with TestDependencies config in Tests

replace servicelocator usage in analytics service

replace remaining ServiceLocator calls in unit tests

that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances

fix swiftlint warning

remove remaining references to ServiceLocator

make dependencies properties consistent in tests

delete Dependencies and DependenciesProtocol

fix whitespace modifications

updates from PR feedback

a pass over converting AppSettings and AnalyticsService test instance properties to per test instantiation

fix a missed PR feedback

Sync up after rebase

create DependenciesProtocol and replace ServiceLocator registration with it

replace mocking calls for ServiceLocator to Dependencies

replace ServiceLocator with instance level calls

replace ServiceLocator configs with TestDependencies config in Tests

replace servicelocator usage in analytics service

replace remaining ServiceLocator calls in unit tests

that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances

fix swiftlint warning

remove remaining references to ServiceLocator

make dependencies properties consistent in tests

delete Dependencies and DependenciesProtocol

* AppSettings is instance level mockable now

* replace mocked and preview usages of reset + AppSettings global init with

AppSettings(store: UserDefaultsMock())

* AppSettings.UserDefaultsKeys is now a RawRep struct, making it extensible

* update tests with new AppSettings so it builds

* update remaining AppSettings test/mock inits

* AppSettings.UserDefaultsKey conformed to ExpressibleByStringLiteral

* addressed issues found in first pass ai review

* update AppSettings instantiations after rebase

* fix accessibility tests coordinator after rebase

* Rename to VolatileUserDefaults

* AppSettings init uses mandatory parameter

* Revert UserDefaultsKey to enum

* Add TrackedUserDefaults to be able to reset the defaults object

* Added volatile static func to AppSettings

* Use .volatile() static func instead of full init

* review fixups

* Resolve build issues after rebase

* updated enterprise module

* remove the static reset settings call from the preview tests

---------

Co-authored-by: Mauro Romito <mauro.romito@element.io>
This commit is contained in:
Michael
2026-05-22 08:14:08 -05:00
committed by GitHub
parent 97d4a35af7
commit 22d14ff8e4
122 changed files with 572 additions and 747 deletions
@@ -37,7 +37,7 @@ enum AppBuildType {
///
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
final class AppSettings: @unchecked Sendable {
private enum UserDefaultsKeys: String {
fileprivate enum UserDefaultsKeys: String, PreferenceKeyable {
case lastVersionLaunched
case seenInvites
case hasSeenNewSoundBanner
@@ -94,10 +94,10 @@ final class AppSettings: @unchecked Sendable {
case developerOptionsEnabled
}
private nonisolated(unsafe) static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
static let suiteName: String = InfoPlistReader.main.appGroupIdentifier
/// UserDefaults to be used on reads and writes. `UserDefaults` is thread-safe per Apple's documentation.
private nonisolated(unsafe) static var store: UserDefaults! = UserDefaults(suiteName: suiteName)
/// UserDefaults to be used on reads and writes.
private let store: UserDefaultsProtocol
static var appBuildType: AppBuildType {
#if DEBUG
@@ -112,26 +112,16 @@ final class AppSettings: @unchecked Sendable {
#endif
}
static func resetAllSettings() {
func resetAllSettings() {
MXLog.warning("Resetting the AppSettings.")
store.removePersistentDomain(forName: suiteName)
store.reset()
}
static func resetSessionSpecificSettings() {
func resetSessionSpecificSettings() {
MXLog.warning("Resetting the user session specific AppSettings.")
store.removeObject(forKey: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding.rawValue)
}
static func configureWithSuiteName(_ name: String) {
suiteName = name
guard let userDefaults = UserDefaults(suiteName: name) else {
fatalError("Fail to load shared UserDefaults")
}
store = userDefaults
}
// MARK: - Hooks
// swiftlint:disable:next function_parameter_count
@@ -182,17 +172,17 @@ final class AppSettings: @unchecked Sendable {
/// The last known version of the app that was launched on this device, which is
/// used to detect when migrations should be run. When `nil` the app may have been
/// deleted between runs so should clear data in the shared container and keychain.
@UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store))
@UserPreference
var lastVersionLaunched: String?
/// The Set of room identifiers of invites that the user already saw in the invites list.
/// This Set is being used to implement badges for unread invites.
@UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store))
@UserPreference
var seenInvites: Set<String>
/// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users.
@UserPreference(key: UserDefaultsKeys.hasSeenNewSoundBanner, defaultValue: true, storageType: .userDefaults(store))
var hasSeenNewSoundBanner
@UserPreference
var hasSeenNewSoundBanner: Bool
/// The initial set of account providers shown to the user in the authentication flow.
///
@@ -236,7 +226,7 @@ final class AppSettings: @unchecked Sendable {
/// **Note:** This property isn't overridable as it in unexpected for forks to come across the error (or to even have a "Pro" app).
let elementProAppStoreURL: URL = "https://apps.apple.com/app/element-pro-for-work/id6502951615"
@UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store))
@UserPreference
var appAppearance: AppAppearance
// MARK: - Security
@@ -248,7 +238,7 @@ final class AppSettings: @unchecked Sendable {
/// Any codes that the user isn't allowed to use for their PIN.
let appLockPINCodeBlockList = ["0000", "1234"]
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
@UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store))
@UserPreference
var appLockNumberOfPINAttempts: Int
// MARK: - Authentication
@@ -287,33 +277,33 @@ final class AppSettings: @unchecked Sendable {
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
}
@UserPreference(key: UserDefaultsKeys.enableNotifications, defaultValue: true, storageType: .userDefaults(store))
var enableNotifications
@UserPreference
var enableNotifications: Bool
@UserPreference(key: UserDefaultsKeys.enableInAppNotifications, defaultValue: true, storageType: .userDefaults(store))
var enableInAppNotifications
@UserPreference
var enableInAppNotifications: Bool
@UserPreference(key: UserDefaultsKeys.hideQuietNotificationAlerts, defaultValue: false, storageType: .userDefaults(store))
var hideQuietNotificationAlerts
@UserPreference
var hideQuietNotificationAlerts: Bool
/// Tag describing which set of device specific rules a pusher executes.
@UserPreference(key: UserDefaultsKeys.pusherProfileTag, storageType: .userDefaults(store))
@UserPreference
var pusherProfileTag: String?
/// The device's last boot time as recorded by the NSE.
@UserPreference(key: UserDefaultsKeys.lastNotificationBootTime, storageType: .userDefaults(store))
@UserPreference
var lastNotificationBootTime: TimeInterval?
/// The sound played when delivering noisy notifications. If nil, use the ElementX default
@UserPreference(key: UserDefaultsKeys.selectedNotificationTone, defaultValue: nil, storageType: .userDefaults(store))
@UserPreference
var selectedNotificationTone: NotificationTone?
// MARK: - Logging
@UserPreference(key: UserDefaultsKeys.logLevel, defaultValue: LogLevel.info, storageType: .userDefaults(store))
var logLevel
@UserPreference
var logLevel: LogLevel
@UserPreference(key: UserDefaultsKeys.traceLogPacks, defaultValue: [], storageType: .userDefaults(store))
@UserPreference
var traceLogPacks: Set<TraceLogPack>
// MARK: - Bug report
@@ -341,46 +331,46 @@ final class AppSettings: @unchecked Sendable {
}
/// Whether the user has opted in to send analytics.
@UserPreference(key: UserDefaultsKeys.analyticsConsentState, defaultValue: AnalyticsConsentState.unknown, storageType: .userDefaults(store))
var analyticsConsentState
@UserPreference
var analyticsConsentState: AnalyticsConsentState
@UserPreference(key: UserDefaultsKeys.hasRunNotificationPermissionsOnboarding, defaultValue: false, storageType: .userDefaults(store))
var hasRunNotificationPermissionsOnboarding
@UserPreference
var hasRunNotificationPermissionsOnboarding: Bool
@UserPreference(key: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding, defaultValue: false, storageType: .userDefaults(store))
var hasRunIdentityConfirmationOnboarding
@UserPreference
var hasRunIdentityConfirmationOnboarding: Bool
@UserPreference(key: UserDefaultsKeys.hasRequestedLocationAlwaysLocationAuthorization, defaultValue: false, storageType: .userDefaults(store))
var hasRequestedLocationAlwaysLocationAuthorization
@UserPreference
var hasRequestedLocationAlwaysLocationAuthorization: Bool
@UserPreference(key: UserDefaultsKeys.frequentlyUsedSystemEmojis, defaultValue: [FrequentlyUsedEmoji](), storageType: .userDefaults(store))
var frequentlyUsedSystemEmojis
@UserPreference
var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji]
// MARK: - Live Location
@UserPreference(key: UserDefaultsKeys.liveLocationSharingTimeoutDatesByRoomID, defaultValue: [String: LiveLocationSession](), storageType: .userDefaults(store))
var liveLocationSharingSessionsByRoomID
@UserPreference
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession]
@UserPreference(key: UserDefaultsKeys.liveLocationMinimumDistanceUpdate, defaultValue: 10, storageType: .userDefaults(store))
var liveLocationMinimumDistanceUpdate
@UserPreference
var liveLocationMinimumDistanceUpdate: Int
@UserPreference(key: UserDefaultsKeys.liveLocationDisclaimerDisplayed, defaultValue: false, storageType: .userDefaults(store))
var liveLocationDisclaimerDisplayed
@UserPreference
var liveLocationDisclaimerDisplayed: Bool
// MARK: - Home Screen
@UserPreference(key: UserDefaultsKeys.roomListActivityVisibility, defaultValue: .current, storageType: .userDefaults(store))
@UserPreference
var roomListActivityVisibility: RoomListActivityVisibility
// MARK: - Room Screen
@UserPreference(key: UserDefaultsKeys.viewSourceEnabled, defaultValue: appBuildType == .debug, storageType: .userDefaults(store))
var viewSourceEnabled
@UserPreference
var viewSourceEnabled: Bool
@UserPreference(key: UserDefaultsKeys.optimizeMediaUploads, defaultValue: true, storageType: .userDefaults(store))
var optimizeMediaUploads
@UserPreference
var optimizeMediaUploads: Bool
@UserPreference(key: UserDefaultsKeys.voiceMessagePlaybackSpeed, defaultValue: AudioPlaybackSpeed.default, storageType: .userDefaults(store))
@UserPreference
var voiceMessagePlaybackSpeed: AudioPlaybackSpeed
/// Whether or not to show a warning on the media caption composer so the user knows
@@ -399,7 +389,7 @@ final class AppSettings: @unchecked Sendable {
let elementCallPosthogAPIKey = "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU"
let elementCallPosthogSentryDSN = "https://3bd2f95ba5554d4497da7153b552ffb5@sentry.tools.element.io/41"
@UserPreference(key: UserDefaultsKeys.elementCallBaseURLOverride, defaultValue: nil, storageType: .userDefaults(store))
@UserPreference
var elementCallBaseURLOverride: URL?
// MARK: - Users
@@ -417,51 +407,111 @@ final class AppSettings: @unchecked Sendable {
// MARK: - Presence
@UserPreference(key: UserDefaultsKeys.sharePresence, defaultValue: true, storageType: .userDefaults(store))
var sharePresence
@UserPreference
var sharePresence: Bool
// MARK: - Feature Flags
/// Others
@UserPreference(key: UserDefaultsKeys.fuzzyRoomListSearchEnabled, defaultValue: false, storageType: .userDefaults(store))
var fuzzyRoomListSearchEnabled
@UserPreference
var fuzzyRoomListSearchEnabled: Bool
@UserPreference(key: UserDefaultsKeys.lowPriorityFilterEnabled, defaultValue: false, storageType: .userDefaults(store))
var lowPriorityFilterEnabled
@UserPreference
var lowPriorityFilterEnabled: Bool
/// Configuration to enable only signed device isolation mode for crypto. In this mode only devices signed by their owner will be considered in e2ee rooms.
@UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
var enableOnlySignedDeviceIsolationMode
@UserPreference
var enableOnlySignedDeviceIsolationMode: Bool
@UserPreference(key: UserDefaultsKeys.knockingEnabled, defaultValue: false, storageType: .userDefaults(store))
var knockingEnabled
@UserPreference
var knockingEnabled: Bool
@UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store))
var threadsEnabled
@UserPreference
var threadsEnabled: Bool
@UserPreference(key: UserDefaultsKeys.roomThreadListEnabled, defaultValue: false, storageType: .userDefaults(store))
var roomThreadListEnabled
@UserPreference
var roomThreadListEnabled: Bool
@UserPreference(key: UserDefaultsKeys.focusEventOnNotificationTap, defaultValue: false, storageType: .userDefaults(store))
var focusEventOnNotificationTap
@UserPreference
var focusEventOnNotificationTap: Bool
@UserPreference(key: UserDefaultsKeys.linkPreviewsEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkPreviewsEnabled
@UserPreference
var linkPreviewsEnabled: Bool
@UserPreference
var jumpToReadMarkerEnabled: Bool
@UserPreference(key: UserDefaultsKeys.jumpToReadMarkerEnabled, defaultValue: false, storageType: .userDefaults(store))
var jumpToReadMarkerEnabled
@UserPreference(key: UserDefaultsKeys.linkNewDeviceEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkNewDeviceEnabled
@UserPreference
var linkNewDeviceEnabled: Bool
@UserPreference(key: UserDefaultsKeys.automaticBackPaginationEnabled, defaultValue: false, storageType: .userDefaults(store))
var automaticBackPaginationEnabled
@UserPreference
var automaticBackPaginationEnabled: Bool
@UserPreference(key: UserDefaultsKeys.clientPausingAndResumingEnabled, defaultValue: appBuildType != .release, storageType: .volatile)
var clientPausingAndResumingEnabled
@UserPreference
var clientPausingAndResumingEnabled: Bool
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: appBuildType != .release, storageType: .userDefaults(store))
var developerOptionsEnabled
@UserPreference
var developerOptionsEnabled: Bool
init(store: UserDefaultsProtocol) {
// UserDefaults to be used on reads and writes.
self.store = store
_lastVersionLaunched = UserPreference(key: .lastVersionLaunched, storage: store)
_seenInvites = UserPreference(key: .seenInvites, defaultValue: [], storage: store)
_hasSeenNewSoundBanner = UserPreference(key: .hasSeenNewSoundBanner, defaultValue: true, storage: store)
_appAppearance = UserPreference(key: .appAppearance, defaultValue: .system, storage: store)
_appLockNumberOfPINAttempts = UserPreference(key: .appLockNumberOfPINAttempts, defaultValue: 0, storage: store)
_enableNotifications = UserPreference(key: .enableNotifications, defaultValue: true, storage: store)
_enableInAppNotifications = UserPreference(key: .enableInAppNotifications, defaultValue: true, storage: store)
_hideQuietNotificationAlerts = UserPreference(key: .hideQuietNotificationAlerts, defaultValue: false, storage: store)
_pusherProfileTag = UserPreference(key: .pusherProfileTag, storage: store)
_lastNotificationBootTime = UserPreference(key: .lastNotificationBootTime, storage: store)
_selectedNotificationTone = UserPreference(key: .selectedNotificationTone, storage: store)
_logLevel = UserPreference(key: .logLevel, defaultValue: LogLevel.info, storage: store)
_traceLogPacks = UserPreference(key: .traceLogPacks, defaultValue: [], storage: store)
_analyticsConsentState = UserPreference(key: .analyticsConsentState, defaultValue: AnalyticsConsentState.unknown, storage: store)
_hasRunNotificationPermissionsOnboarding = UserPreference(key: .hasRunNotificationPermissionsOnboarding, defaultValue: false, storage: store)
_hasRunIdentityConfirmationOnboarding = UserPreference(key: .hasRunIdentityConfirmationOnboarding, defaultValue: false, storage: store)
_hasRequestedLocationAlwaysLocationAuthorization = UserPreference(key: .hasRequestedLocationAlwaysLocationAuthorization, defaultValue: false, storage: store)
_frequentlyUsedSystemEmojis = UserPreference(key: .frequentlyUsedSystemEmojis, defaultValue: [FrequentlyUsedEmoji](), storage: store)
_liveLocationSharingSessionsByRoomID = UserPreference(key: .liveLocationSharingTimeoutDatesByRoomID, defaultValue: [String: LiveLocationSession](), storage: store)
_liveLocationMinimumDistanceUpdate = UserPreference(key: .liveLocationMinimumDistanceUpdate, defaultValue: 10, storage: store)
_liveLocationDisclaimerDisplayed = UserPreference(key: .liveLocationDisclaimerDisplayed, defaultValue: false, storage: store)
_roomListActivityVisibility = UserPreference(key: .roomListActivityVisibility, defaultValue: .current, storage: store)
_viewSourceEnabled = UserPreference(key: .viewSourceEnabled, defaultValue: Self.appBuildType == .debug, storage: store)
_optimizeMediaUploads = UserPreference(key: .optimizeMediaUploads, defaultValue: true, storage: store)
_voiceMessagePlaybackSpeed = UserPreference(key: .voiceMessagePlaybackSpeed, defaultValue: AudioPlaybackSpeed.default, storage: store)
_elementCallBaseURLOverride = UserPreference(key: .elementCallBaseURLOverride, defaultValue: nil, storage: store)
_sharePresence = UserPreference(key: .sharePresence, defaultValue: true, storage: store)
_fuzzyRoomListSearchEnabled = UserPreference(key: .fuzzyRoomListSearchEnabled, defaultValue: false, storage: store)
_lowPriorityFilterEnabled = UserPreference(key: .lowPriorityFilterEnabled, defaultValue: false, storage: store)
_enableOnlySignedDeviceIsolationMode = UserPreference(key: .enableOnlySignedDeviceIsolationMode, defaultValue: false, storage: store)
_knockingEnabled = UserPreference(key: .knockingEnabled, defaultValue: false, storage: store)
_threadsEnabled = UserPreference(key: .threadsEnabled, defaultValue: false, storage: store)
_roomThreadListEnabled = UserPreference(key: .roomThreadListEnabled, defaultValue: false, storage: store)
_focusEventOnNotificationTap = UserPreference(key: .focusEventOnNotificationTap, defaultValue: false, storage: store)
_linkPreviewsEnabled = UserPreference(key: .linkPreviewsEnabled, defaultValue: false, storage: store)
_jumpToReadMarkerEnabled = UserPreference(key: .jumpToReadMarkerEnabled, defaultValue: false, storage: store)
_linkNewDeviceEnabled = UserPreference(key: .linkNewDeviceEnabled, defaultValue: false, storage: store)
_automaticBackPaginationEnabled = UserPreference(key: .automaticBackPaginationEnabled, defaultValue: false, storage: store)
_clientPausingAndResumingEnabled = UserPreference(key: .clientPausingAndResumingEnabled, defaultValue: Self.appBuildType != .release, storage: VolatileUserDefaults())
_developerOptionsEnabled = UserPreference(key: .developerOptionsEnabled, defaultValue: Self.appBuildType != .release, storage: store)
}
static func volatile() -> AppSettings {
AppSettings(store: VolatileUserDefaults())
}
}
extension AppSettings: CommonSettingsProtocol { }
private extension UserPreference {
convenience init(key: AppSettings.UserDefaultsKeys, defaultValue: T, storage backingStorage: UserDefaultsProtocol, mode: Mode = .localOverRemote) {
self.init(key: key as any PreferenceKeyable, defaultValue: defaultValue, storage: backingStorage, mode: mode)
}
convenience init(key: AppSettings.UserDefaultsKeys, storage: UserDefaultsProtocol, mode: Mode = .localOverRemote) where T: ExpressibleByNilLiteral {
self.init(key: key as any PreferenceKeyable, defaultValue: nil, storage: storage, mode: mode)
}
}