Replace the UserPreference property wrapper with the new macro version.

This commit is contained in:
Doug
2026-06-26 13:46:14 +01:00
committed by Doug
parent c53e0c1466
commit f05fd41c36
17 changed files with 143 additions and 388 deletions
@@ -170,7 +170,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
registerBackgroundAppRefresh() registerBackgroundAppRefresh()
appSettings.$analyticsConsentState appSettings.analyticsConsentStatePublisher
.dropFirst() // Sentry is configured during init; only reconfigure when consent state actually changes .dropFirst() // Sentry is configured during init; only reconfigure when consent state actually changes
.sink { [bugReportService, analyticsService, appSettings] _ in .sink { [bugReportService, analyticsService, appSettings] _ in
Self.setupSentry(bugReportService: bugReportService, appSettings: appSettings, analytics: analyticsService) Self.setupSentry(bugReportService: bugReportService, appSettings: appSettings, analytics: analyticsService)
@@ -221,7 +221,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
func toPresentable() -> AnyView { func toPresentable() -> AnyView {
AnyView(navigationRootCoordinator.toPresentable() AnyView(navigationRootCoordinator.toPresentable()
.environment(\.analyticsService, analyticsService) .environment(\.analyticsService, analyticsService)
.onReceive(appSettings.$appAppearance) { [weak self] appAppearance in .onReceive(appSettings.appAppearancePublisher) { [weak self] appAppearance in
guard let self else { return } guard let self else { return }
windowManager.windows.forEach { window in windowManager.windows.forEach { window in
@@ -10,7 +10,9 @@
import EmbeddedElementCall import EmbeddedElementCall
#endif #endif
import Combine
import Foundation import Foundation
import Macros
import SwiftUI import SwiftUI
/// Common settings between app and NSE /// Common settings between app and NSE
@@ -32,76 +34,8 @@ nonisolated enum AppBuildType {
case debug case debug
case nightly case nightly
case release case release
}
/// Store Element specific app settings. static var current: AppBuildType {
///
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
final nonisolated class AppSettings: @unchecked Sendable {
fileprivate enum UserDefaultsKeys: String, PreferenceKeyable {
case lastVersionLaunched
case seenInvites
case hasSeenNewSoundBanner
case appLockNumberOfPINAttempts
case appLockNumberOfBiometricAttempts
case timelineStyle
case analyticsConsentState
case hasRunNotificationPermissionsOnboarding
case hasRunIdentityConfirmationOnboarding
case hasRequestedLocationAlwaysLocationAuthorization
case frequentlyUsedSystemEmojis
case enableNotifications
case enableInAppNotifications
case pusherProfileTag
case lastNotificationBootTime
case selectedNotificationTone
case logLevel
case traceLogPacks
case viewSourceEnabled
case optimizeMediaUploads
case appAppearance
case sharePresence
case elementCallBaseURLOverride
case voiceMessagePlaybackSpeed
// Live Location
case liveLocationSharingTimeoutDatesByRoomID
case liveLocationMinimumDistanceUpdate
case liveLocationDisclaimerDisplayed
// Feature flags
case fuzzyRoomListSearchEnabled
case lowPriorityFilterEnabled
case enableOnlySignedDeviceIsolationMode
case knockingEnabled
case threadsEnabled
case globalSearchEnabled
case roomThreadListEnabled
case linkPreviewsEnabled
case jumpToReadMarkerEnabled
case focusEventOnNotificationTap
case linkNewDeviceEnabled
case automaticBackPaginationEnabled
case clientPausingAndResumingEnabled
// Doug's tweaks 🔧
case roomListActivityVisibility
case hideQuietNotificationAlerts
case developerOptionsEnabled
}
static let suiteName: String = InfoPlistReader.main.appGroupIdentifier
/// UserDefaults to be used on reads and writes.
private let store: UserDefaultsProtocol
static var appBuildType: AppBuildType {
#if DEBUG #if DEBUG
return .debug return .debug
#else #else
@@ -113,6 +47,20 @@ final nonisolated class AppSettings: @unchecked Sendable {
} }
#endif #endif
} }
}
/// Store Element specific app settings.
///
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
final nonisolated class AppSettings: @unchecked Sendable {
static let suiteName: String = InfoPlistReader.main.appGroupIdentifier
/// UserDefaults to be used on reads and writes.
private let store: UserDefaultsProtocol
static var appBuildType: AppBuildType {
AppBuildType.current
}
func resetAllSettings() { func resetAllSettings() {
MXLog.warning("Resetting the AppSettings.") MXLog.warning("Resetting the AppSettings.")
@@ -121,7 +69,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
func resetSessionSpecificSettings() { func resetSessionSpecificSettings() {
MXLog.warning("Resetting the user session specific AppSettings.") MXLog.warning("Resetting the user session specific AppSettings.")
store.removeObject(forKey: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding.rawValue) resetHasRunIdentityConfirmationOnboarding()
} }
// MARK: - Hooks // MARK: - Hooks
@@ -175,16 +123,16 @@ final nonisolated class AppSettings: @unchecked Sendable {
/// used to detect when migrations should be run. When `nil` the app may have been /// 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. /// deleted between runs so should clear data in the shared container and keychain.
@UserPreference @UserPreference
nonisolated(unsafe) var lastVersionLaunched: String? var lastVersionLaunched: String?
/// The Set of room identifiers of invites that the user already saw in the invites list. /// 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. /// This Set is being used to implement badges for unread invites.
@UserPreference @UserPreference(defaultValue: Set<String>())
nonisolated(unsafe) var seenInvites: Set<String> var seenInvites: Set<String>
/// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users. /// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users.
@UserPreference @UserPreference(defaultValue: true)
nonisolated(unsafe) var hasSeenNewSoundBanner: Bool var hasSeenNewSoundBanner: Bool
/// The initial set of account providers shown to the user in the authentication flow. /// The initial set of account providers shown to the user in the authentication flow.
/// ///
@@ -228,8 +176,8 @@ final nonisolated 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). /// **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" let elementProAppStoreURL: URL = "https://apps.apple.com/app/element-pro-for-work/id6502951615"
@UserPreference @UserPreference(defaultValue: AppAppearance.system)
nonisolated(unsafe) var appAppearance: AppAppearance var appAppearance: AppAppearance
// MARK: - Security // MARK: - Security
@@ -240,8 +188,8 @@ final nonisolated class AppSettings: @unchecked Sendable {
/// Any codes that the user isn't allowed to use for their PIN. /// Any codes that the user isn't allowed to use for their PIN.
let appLockPINCodeBlockList = ["0000", "1234"] let appLockPINCodeBlockList = ["0000", "1234"]
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked). /// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
@UserPreference @UserPreference(defaultValue: 0)
nonisolated(unsafe) var appLockNumberOfPINAttempts: Int var appLockNumberOfPINAttempts: Int
// MARK: - Authentication // MARK: - Authentication
@@ -281,34 +229,34 @@ final nonisolated class AppSettings: @unchecked Sendable {
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify") pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
} }
@UserPreference @UserPreference(defaultValue: true)
nonisolated(unsafe) var enableNotifications: Bool var enableNotifications: Bool
@UserPreference @UserPreference(defaultValue: true)
nonisolated(unsafe) var enableInAppNotifications: Bool var enableInAppNotifications: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var hideQuietNotificationAlerts: Bool var hideQuietNotificationAlerts: Bool
/// Tag describing which set of device specific rules a pusher executes. /// Tag describing which set of device specific rules a pusher executes.
@UserPreference @UserPreference
nonisolated(unsafe) var pusherProfileTag: String? var pusherProfileTag: String?
/// The device's last boot time as recorded by the NSE. /// The device's last boot time as recorded by the NSE.
@UserPreference @UserPreference
nonisolated(unsafe) var lastNotificationBootTime: TimeInterval? var lastNotificationBootTime: TimeInterval?
/// The sound played when delivering noisy notifications. If nil, use the ElementX default /// The sound played when delivering noisy notifications. If nil, use the ElementX default
@UserPreference @UserPreference
nonisolated(unsafe) var selectedNotificationTone: NotificationTone? var selectedNotificationTone: NotificationTone?
// MARK: - Logging // MARK: - Logging
@UserPreference @UserPreference(defaultValue: LogLevel.info)
nonisolated(unsafe) var logLevel: LogLevel var logLevel: LogLevel
@UserPreference @UserPreference(defaultValue: Set<TraceLogPack>())
nonisolated(unsafe) var traceLogPacks: Set<TraceLogPack> var traceLogPacks: Set<TraceLogPack>
// MARK: - Bug report // MARK: - Bug report
@@ -335,47 +283,47 @@ final nonisolated class AppSettings: @unchecked Sendable {
} }
/// Whether the user has opted in to send analytics. /// Whether the user has opted in to send analytics.
@UserPreference @UserPreference(defaultValue: AnalyticsConsentState.unknown)
nonisolated(unsafe) var analyticsConsentState: AnalyticsConsentState var analyticsConsentState: AnalyticsConsentState
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var hasRunNotificationPermissionsOnboarding: Bool var hasRunNotificationPermissionsOnboarding: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var hasRunIdentityConfirmationOnboarding: Bool var hasRunIdentityConfirmationOnboarding: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var hasRequestedLocationAlwaysLocationAuthorization: Bool var hasRequestedLocationAlwaysLocationAuthorization: Bool
@UserPreference @UserPreference(defaultValue: [FrequentlyUsedEmoji]())
nonisolated(unsafe) var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji] var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji]
// MARK: - Live Location // MARK: - Live Location
@UserPreference @UserPreference(key: "liveLocationSharingTimeoutDatesByRoomID", defaultValue: [String: LiveLocationSession]())
nonisolated(unsafe) var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession]
@UserPreference @UserPreference(defaultValue: 10)
nonisolated(unsafe) var liveLocationMinimumDistanceUpdate: Int var liveLocationMinimumDistanceUpdate: Int
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var liveLocationDisclaimerDisplayed: Bool var liveLocationDisclaimerDisplayed: Bool
// MARK: - Home Screen // MARK: - Home Screen
@UserPreference @UserPreference(defaultValue: RoomListActivityVisibility.current)
nonisolated(unsafe) var roomListActivityVisibility: RoomListActivityVisibility var roomListActivityVisibility: RoomListActivityVisibility
// MARK: - Room Screen // MARK: - Room Screen
@UserPreference @UserPreference(defaultValue: AppBuildType.current == .debug)
nonisolated(unsafe) var viewSourceEnabled: Bool var viewSourceEnabled: Bool
@UserPreference @UserPreference(defaultValue: true)
nonisolated(unsafe) var optimizeMediaUploads: Bool var optimizeMediaUploads: Bool
@UserPreference @UserPreference(defaultValue: AudioPlaybackSpeed.default)
nonisolated(unsafe) var voiceMessagePlaybackSpeed: AudioPlaybackSpeed var voiceMessagePlaybackSpeed: AudioPlaybackSpeed
/// Whether or not to show a warning on the media caption composer so the user knows /// Whether or not to show a warning on the media caption composer so the user knows
/// that captions might not be visible to users who are using other Matrix clients. /// that captions might not be visible to users who are using other Matrix clients.
@@ -394,7 +342,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
let elementCallPosthogSentryDSN = "https://3bd2f95ba5554d4497da7153b552ffb5@sentry.tools.element.io/41" let elementCallPosthogSentryDSN = "https://3bd2f95ba5554d4497da7153b552ffb5@sentry.tools.element.io/41"
@UserPreference @UserPreference
nonisolated(unsafe) var elementCallBaseURLOverride: URL? var elementCallBaseURLOverride: URL?
// MARK: - Users // MARK: - Users
@@ -416,100 +364,57 @@ final nonisolated class AppSettings: @unchecked Sendable {
// MARK: - Presence // MARK: - Presence
@UserPreference @UserPreference(defaultValue: true)
nonisolated(unsafe) var sharePresence: Bool var sharePresence: Bool
// MARK: - Feature Flags // MARK: - Feature Flags
/// Others /// Others
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var fuzzyRoomListSearchEnabled: Bool var fuzzyRoomListSearchEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var lowPriorityFilterEnabled: Bool 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. /// 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 @UserPreference(defaultValue: false)
nonisolated(unsafe) var enableOnlySignedDeviceIsolationMode: Bool var enableOnlySignedDeviceIsolationMode: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var knockingEnabled: Bool var knockingEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var threadsEnabled: Bool var threadsEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var roomThreadListEnabled: Bool var roomThreadListEnabled: Bool
@UserPreference @UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac)
nonisolated(unsafe) var globalSearchEnabled: Bool var globalSearchEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var focusEventOnNotificationTap: Bool var focusEventOnNotificationTap: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var linkPreviewsEnabled: Bool var linkPreviewsEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var jumpToReadMarkerEnabled: Bool var jumpToReadMarkerEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var linkNewDeviceEnabled: Bool var linkNewDeviceEnabled: Bool
@UserPreference @UserPreference(defaultValue: false)
nonisolated(unsafe) var automaticBackPaginationEnabled: Bool var automaticBackPaginationEnabled: Bool
@UserPreference @UserPreference(defaultValue: AppBuildType.current != .release, volatile: true)
nonisolated(unsafe) var clientPausingAndResumingEnabled: Bool var clientPausingAndResumingEnabled: Bool
@UserPreference @UserPreference(defaultValue: AppBuildType.current != .release)
nonisolated(unsafe) var developerOptionsEnabled: Bool var developerOptionsEnabled: Bool
init(store: UserDefaultsProtocol) { init(store: UserDefaultsProtocol) {
// UserDefaults to be used on reads and writes.
self.store = store 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)
_globalSearchEnabled = UserPreference(key: .globalSearchEnabled, defaultValue: ProcessInfo().isiOSAppOnMac, 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 { static func volatile() -> AppSettings {
@@ -518,13 +423,3 @@ final nonisolated class AppSettings: @unchecked Sendable {
} }
nonisolated extension AppSettings: CommonSettingsProtocol { } nonisolated extension AppSettings: CommonSettingsProtocol { }
private nonisolated 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)
}
}
@@ -6,123 +6,15 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Combine
import Foundation import Foundation
/// A property wrapper that allows storing data in a keyed storage while also exposing a Combine publisher
/// to listen for value changes. The publisher does not skip consecutive duplicates, as there is no
/// `Equatable` enforcement at this level.
@propertyWrapper
final nonisolated class UserPreference<T: Codable> {
static var remotePrefix: String {
"remote-"
}
enum Mode {
case localOverRemote
case remoteOverLocal
}
private let key: String
private var remoteKey: String {
"\(Self.remotePrefix)\(key)"
}
private var keyedStorage: any KeyedStorage<T>
private let defaultValue: T
private let subject: PassthroughSubject<T, Never> = .init()
private let mode: Mode
/// This can be used to check if is still possible for the user to change the value or not
/// Can only be accessed by using `_preferenceName.isLockedToRemote`
var isLockedToRemote: Bool {
mode == .remoteOverLocal && remoteValue != nil
}
/// Initializes the property wrapper with a static default value.
///
/// - Parameters:
/// - key: The key used to store and retrieve the value.
/// - defaultValue: The default value to use if no stored value exists or if `forceDefault` is `true`.
/// - keyedStorage: The storage instance where the value is saved.
/// - forceDefault: A publisher that determines whether the default value should always be used. Defaults to publish `false`. Useful in the context of remote settings that need to override the local value.
init(key: any PreferenceKeyable,
defaultValue: T,
keyedStorage: any KeyedStorage<T>,
mode: Mode) {
self.key = key.rawValue
self.defaultValue = defaultValue
self.keyedStorage = keyedStorage
self.mode = mode
}
/// The wrapped value is supposed to be the one updated by the user so it can only control the local value
var wrappedValue: T {
get {
switch mode {
case .localOverRemote:
return keyedStorage[key] ?? keyedStorage[remoteKey] ?? defaultValue
case .remoteOverLocal:
return keyedStorage[remoteKey] ?? keyedStorage[key] ?? defaultValue
}
}
set {
keyedStorage[key] = newValue
subject.send(wrappedValue)
}
}
/// This is supposed to be the value that is set by the remote settings
/// So it can only be accessed by doing `AppSettings._preferenceName.remoteValue`
var remoteValue: T? {
get {
keyedStorage[remoteKey]
} set {
keyedStorage[remoteKey] = newValue
if mode == .remoteOverLocal || keyedStorage[key] == nil {
subject.send(wrappedValue)
}
}
}
var projectedValue: AnyPublisher<T, Never> {
subject
.prepend(wrappedValue)
.eraseToAnyPublisher()
}
}
// MARK: - UserPreference convenience initializers
nonisolated extension UserPreference {
convenience init(key: any PreferenceKeyable, defaultValue: T, storage backingStorage: UserDefaultsProtocol, mode: Mode = .localOverRemote) {
let storage: any KeyedStorage<T> = UserDefaultsStorage(userDefaults: backingStorage)
self.init(key: key, defaultValue: defaultValue, keyedStorage: storage, mode: mode)
}
convenience init(key: any PreferenceKeyable, storage: UserDefaultsProtocol, mode: Mode = .localOverRemote) where T: ExpressibleByNilLiteral {
self.init(key: key, defaultValue: nil, storage: storage, mode: mode)
}
}
// MARK: - Keys
nonisolated protocol PreferenceKeyable: RawRepresentable where RawValue == String { }
// MARK: - Storage // MARK: - Storage
nonisolated protocol KeyedStorage<Value> { /// Typed storage of a `Codable` value in `UserDefaults`, used by the `@UserPreference` macro.
associatedtype Value: Codable
subscript(key: String) -> Value? { get set }
}
/// An implementation of KeyedStorage on the UserDefaults.
/// ///
/// When used with a `Value` that conforms to `PlistRepresentable` the Codable encode/decode /// When used with a `Value` that conforms to `PlistRepresentable` the Codable encode/decode
/// phase is skipped, and values are stored natively in the plist. /// phase is skipped, and values are stored natively in the plist.
final nonisolated class UserDefaultsStorage<Value: Codable>: KeyedStorage { nonisolated struct UserDefaultsStorage<Value: Codable> {
private let userDefaults: UserDefaultsProtocol private let userDefaults: UserDefaultsProtocol
init(userDefaults: UserDefaultsProtocol) { init(userDefaults: UserDefaultsProtocol) {
@@ -140,7 +32,7 @@ final nonisolated class UserDefaultsStorage<Value: Codable>: KeyedStorage {
return value return value
} }
set { nonmutating set {
if Value.self is PlistRepresentable.Type { if Value.self is PlistRepresentable.Type {
encodePlistRepresentable(value: newValue, for: key) encodePlistRepresentable(value: newValue, for: key)
} else { } else {
@@ -198,8 +90,6 @@ nonisolated extension Optional: Nullable {
} }
} }
nonisolated extension Dictionary: KeyedStorage where Key == String, Value: Codable { }
// MARK: - PlistRepresentable // MARK: - PlistRepresentable
/// A protocol to mark types as being plist compliant. /// A protocol to mark types as being plist compliant.
@@ -118,21 +118,21 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.selectedRoomID, on: self) .weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$roomListActivityVisibility appSettings.roomListActivityVisibilityPublisher
.sink { [weak self] value in .sink { [weak self] value in
self?.state.roomListActivityVisibility = value self?.state.roomListActivityVisibility = value
self?.updateRooms() self?.updateRooms()
} }
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$seenInvites appSettings.seenInvitesPublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.updateRooms() self?.updateRooms()
} }
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$hasSeenNewSoundBanner appSettings.hasSeenNewSoundBannerPublisher
.sink { [weak self] hasSeenNewSoundBanner in .sink { [weak self] hasSeenNewSoundBanner in
self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner
} }
@@ -173,7 +173,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$liveLocationSharingSessionsByRoomID appSettings.liveLocationSharingSessionsByRoomIDPublisher
.map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil } .map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil }
.removeDuplicates() .removeDuplicates()
.sink { [weak self] isSharingLiveLocationOnThisDevice in .sink { [weak self] isSharingLiveLocationOnThisDevice in
@@ -163,11 +163,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Private // MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) { private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
appSettings.$roomThreadListEnabled appSettings.roomThreadListEnabledPublisher
.weakAssign(to: \.state.roomThreadListEnabled, on: self) .weakAssign(to: \.state.roomThreadListEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$liveLocationSharingSessionsByRoomID appSettings.liveLocationSharingSessionsByRoomIDPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] sessionsByRoomID in .sink { [weak self] sessionsByRoomID in
guard let self else { return } guard let self else { return }
@@ -152,7 +152,7 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
.weakAssign(to: \.state.isSpace, on: self) .weakAssign(to: \.state.isSpace, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$knockingEnabled appSettings.knockingEnabledPublisher
.weakAssign(to: \.state.isKnockingEnabled, on: self) .weakAssign(to: \.state.isKnockingEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
} }
@@ -23,7 +23,7 @@ class AnalyticsSettingsScreenViewModel: AnalyticsSettingsScreenViewModelType, An
super.init(initialViewState: state) super.init(initialViewState: state)
appSettings.$analyticsConsentState appSettings.analyticsConsentStatePublisher
.map { $0 == .optedIn } .map { $0 == .optedIn }
.weakAssign(to: \.state.bindings.enableAnalytics, on: self) .weakAssign(to: \.state.bindings.enableAnalytics, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -48,11 +48,11 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac)) customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac))
// Listen for changes to AppSettings. // Listen for changes to AppSettings.
appSettings.$enableNotifications appSettings.enableNotificationsPublisher
.weakAssign(to: \.state.bindings.enableNotifications, on: self) .weakAssign(to: \.state.bindings.enableNotifications, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$selectedNotificationTone appSettings.selectedNotificationTonePublisher
.map { $0 ?? NotificationToneManager.defaultElementXMessageTone } .map { $0 ?? NotificationToneManager.defaultElementXMessageTone }
.weakAssign(to: \.state.selectedAlertTone, on: self) .weakAssign(to: \.state.selectedAlertTone, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -33,11 +33,11 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic), navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
appSettings.$developerOptionsEnabled appSettings.developerOptionsEnabledPublisher
.weakAssign(to: \.state.showDeveloperOptions, on: self) .weakAssign(to: \.state.showDeveloperOptions, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$linkNewDeviceEnabled appSettings.linkNewDeviceEnabledPublisher
.weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .weakAssign(to: \.state.showLinkNewDeviceButton, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -508,7 +508,7 @@ class TimelineInteractionHandler {
duration: voiceMessageRoomTimelineItem.content.duration, duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform, waveform: voiceMessageRoomTimelineItem.content.waveform,
playbackSpeed: appSettings.voiceMessagePlaybackSpeed, playbackSpeed: appSettings.voiceMessagePlaybackSpeed,
playbackSpeedPublisher: appSettings.$voiceMessagePlaybackSpeed) playbackSpeedPublisher: appSettings.voiceMessagePlaybackSpeedPublisher)
mediaPlayerProvider.register(audioPlayerState: playerState) mediaPlayerProvider.register(audioPlayerState: playerState)
return playerState return playerState
} }
@@ -556,19 +556,19 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
private func setupAppSettingsSubscriptions() { private func setupAppSettingsSubscriptions() {
appSettings.$sharePresence appSettings.sharePresencePublisher
.weakAssign(to: \.state.showReadReceipts, on: self) .weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$viewSourceEnabled appSettings.viewSourceEnabledPublisher
.weakAssign(to: \.state.isViewSourceEnabled, on: self) .weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$threadsEnabled appSettings.threadsEnabledPublisher
.weakAssign(to: \.state.areThreadsEnabled, on: self) .weakAssign(to: \.state.areThreadsEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$jumpToReadMarkerEnabled appSettings.jumpToReadMarkerEnabledPublisher
.weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self) .weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -55,7 +55,7 @@ class AppLockService: AppLockServiceProtocol {
} }
var numberOfPINAttempts: AnyPublisher<Int, Never> { var numberOfPINAttempts: AnyPublisher<Int, Never> {
appSettings.$appLockNumberOfPINAttempts appSettings.appLockNumberOfPINAttemptsPublisher
} }
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) { init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) {
@@ -183,7 +183,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$liveLocationSharingSessionsByRoomID appSettings.liveLocationSharingSessionsByRoomIDPublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] sessions in .sink { [weak self] sessions in
guard let self else { return } guard let self else { return }
@@ -197,7 +197,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
.store(in: &cancellables) .store(in: &cancellables)
appSettings.$liveLocationMinimumDistanceUpdate appSettings.liveLocationMinimumDistanceUpdatePublisher
.removeDuplicates() .removeDuplicates()
.debounce(for: .seconds(1), scheduler: DispatchQueue.main) .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] newValue in .sink { [weak self] newValue in
@@ -51,7 +51,7 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
MXLog.info("App setting 'enableNotifications' is '\(notificationsEnabled)'") MXLog.info("App setting 'enableNotifications' is '\(notificationsEnabled)'")
// Listen for changes to AppSettings.enableNotifications // Listen for changes to AppSettings.enableNotifications
appSettings.$enableNotifications appSettings.enableNotificationsPublisher
.sink { [weak self] newValue in .sink { [weak self] newValue in
self?.enableNotifications(newValue) self?.enableNotifications(newValue)
} }
@@ -190,7 +190,7 @@ final class LiveLocationManagerTests {
try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org")
#expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) #expect(appSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil)
let deferred = deferFulfillment(appSettings.$liveLocationSharingSessionsByRoomID) { $0["!room:matrix.org"] == nil } let deferred = deferFulfillment(appSettings.liveLocationSharingSessionsByRoomIDPublisher) { $0["!room:matrix.org"] == nil }
beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: "!room:matrix.org", eventID: "$external_event:matrix.org", isLive: true)) beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: "!room:matrix.org", eventID: "$external_event:matrix.org", isLive: true))
try await deferred.fulfill() try await deferred.fulfill()
@@ -237,7 +237,7 @@ final class LiveLocationManagerTests {
} }
private func simulateBeaconEcho(roomID: String, eventID: String) async throws { private func simulateBeaconEcho(roomID: String, eventID: String) async throws {
let deferred = deferFulfillment(appSettings.$liveLocationSharingSessionsByRoomID) { $0[roomID] != nil } let deferred = deferFulfillment(appSettings.liveLocationSharingSessionsByRoomIDPublisher) { $0[roomID] != nil }
beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: roomID, eventID: eventID, isLive: true)) beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: roomID, eventID: eventID, isLive: true))
try await deferred.fulfill() try await deferred.fulfill()
} }
+20 -50
View File
@@ -6,8 +6,10 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Combine
@testable import ElementX @testable import ElementX
import Foundation import Foundation
import Macros
import Testing import Testing
struct UserPreferenceTests { struct UserPreferenceTests {
@@ -131,58 +133,29 @@ struct UserPreferenceTests {
#expect(value.codable == nil) #expect(value.codable == nil)
#expect(testDefaults.data(forKey: TestsKey.key3.rawValue) == nil) #expect(testDefaults.data(forKey: TestsKey.key3.rawValue) == nil)
} }
@Test
func localOverRemoteValue() {
let storage = VolatileUserDefaults()
let preference = UserPreference(key: TestsKey.testKey, defaultValue: "", storage: storage)
#expect(preference.wrappedValue == "")
preference.remoteValue = "remote"
#expect(preference.wrappedValue == "remote")
preference.wrappedValue = "local"
#expect(preference.wrappedValue == "local")
}
@Test
func remoteOverLocalValue() {
let storage = VolatileUserDefaults()
let preference = UserPreference(key: TestsKey.testKey, defaultValue: "", storage: storage, mode: .remoteOverLocal)
#expect(preference.wrappedValue == "")
preference.remoteValue = "remote"
#expect(preference.wrappedValue == "remote")
preference.wrappedValue = "local"
#expect(preference.wrappedValue == "remote")
#expect(preference.isLockedToRemote)
}
} }
private nonisolated struct TestPreferences { private final class TestPreferences {
@UserPreference let store: UserDefaultsProtocol
init(_ store: UserDefaultsProtocol) {
self.store = store
}
@UserPreference(key: TestsKey.key1.rawValue, volatile: true)
var volatileVar: String? var volatileVar: String?
@UserPreference @UserPreference(key: TestsKey.key2.rawValue)
var plist: String? var plist: String?
@UserPreference @UserPreference(key: TestsKey.key3.rawValue)
var codable: CodableTestType? var codable: CodableTestType?
@UserPreference @UserPreference(key: TestsKey.key4.rawValue, volatile: true)
var volatileCodable: CodableTestType? var volatileCodable: CodableTestType?
@UserPreference @UserPreference(key: TestsKey.key5.rawValue)
var plistArray: [Int]? var plistArray: [Int]?
init(_ storage: UserDefaultsProtocol) {
_volatileVar = UserPreference(key: TestsKey.key1, storage: VolatileUserDefaults())
_plist = UserPreference(key: TestsKey.key2, storage: storage)
_codable = UserPreference(key: TestsKey.key3, storage: storage)
_volatileCodable = UserPreference(key: TestsKey.key4, storage: VolatileUserDefaults())
_plistArray = UserPreference(key: TestsKey.key5, storage: storage)
}
} }
private struct CodableTestType: Equatable, Codable { private struct CodableTestType: Equatable, Codable {
@@ -190,13 +163,10 @@ private struct CodableTestType: Equatable, Codable {
let b: [Int] let b: [Int]
} }
private nonisolated struct TestsKey: PreferenceKeyable, RawRepresentable { private enum TestsKey: String {
static let testKey = Self(rawValue: "testKey") case key1 = "foo.volatile"
static let key1 = Self(rawValue: "foo.volatile") case key2 = "foo.plist"
static let key2 = Self(rawValue: "foo.plist") case key3 = "foo.codable"
static let key3 = Self(rawValue: "foo.codable") case key4 = "foo.volatile.codable"
static let key4 = Self(rawValue: "foo.volatile.codable") case key5 = "foo.plist.array"
static let key5 = Self(rawValue: "foo.plist.array")
let rawValue: String
} }