From f05fd41c3618e5b9d3e3b9b0657ae89a68524837 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 26 Jun 2026 13:46:14 +0100 Subject: [PATCH] Replace the UserPreference property wrapper with the new macro version. --- .../Sources/Application/AppCoordinator.swift | 4 +- .../Application/Settings/AppSettings.swift | 295 ++++++------------ .../Application/Settings/UserPreference.swift | 116 +------ .../HomeScreen/HomeScreenViewModel.swift | 6 +- .../LocationSharingScreenViewModel.swift | 2 +- .../RoomScreen/RoomScreenViewModel.swift | 4 +- .../SecurityAndPrivacyScreenViewModel.swift | 2 +- .../AnalyticsSettingsScreenViewModel.swift | 2 +- .../NotificationSettingsScreenViewModel.swift | 4 +- .../SettingsScreenViewModel.swift | 4 +- .../Timeline/TimelineInteractionHandler.swift | 2 +- .../Screens/Timeline/TimelineViewModel.swift | 8 +- .../Services/AppLock/AppLockService.swift | 2 +- .../Location/LiveLocationManager.swift | 4 +- .../Manager/NotificationManager.swift | 2 +- .../Sources/LiveLocationManagerTests.swift | 4 +- UnitTests/Sources/UserPreferenceTests.swift | 70 ++--- 17 files changed, 143 insertions(+), 388 deletions(-) diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 48111b030..aec209805 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -170,7 +170,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg registerBackgroundAppRefresh() - appSettings.$analyticsConsentState + appSettings.analyticsConsentStatePublisher .dropFirst() // Sentry is configured during init; only reconfigure when consent state actually changes .sink { [bugReportService, analyticsService, appSettings] _ in Self.setupSentry(bugReportService: bugReportService, appSettings: appSettings, analytics: analyticsService) @@ -221,7 +221,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg func toPresentable() -> AnyView { AnyView(navigationRootCoordinator.toPresentable() .environment(\.analyticsService, analyticsService) - .onReceive(appSettings.$appAppearance) { [weak self] appAppearance in + .onReceive(appSettings.appAppearancePublisher) { [weak self] appAppearance in guard let self else { return } windowManager.windows.forEach { window in diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 1a6b93220..7dac65522 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -10,7 +10,9 @@ import EmbeddedElementCall #endif +import Combine import Foundation +import Macros import SwiftUI /// Common settings between app and NSE @@ -32,76 +34,8 @@ nonisolated enum AppBuildType { case debug case nightly case release -} - -/// 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 { - 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 { + static var current: AppBuildType { #if DEBUG return .debug #else @@ -113,6 +47,20 @@ final nonisolated class AppSettings: @unchecked Sendable { } #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() { MXLog.warning("Resetting the AppSettings.") @@ -121,7 +69,7 @@ final nonisolated class AppSettings: @unchecked Sendable { func resetSessionSpecificSettings() { MXLog.warning("Resetting the user session specific AppSettings.") - store.removeObject(forKey: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding.rawValue) + resetHasRunIdentityConfirmationOnboarding() } // 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 /// deleted between runs so should clear data in the shared container and keychain. @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. /// This Set is being used to implement badges for unread invites. - @UserPreference - nonisolated(unsafe) var seenInvites: Set + @UserPreference(defaultValue: Set()) + var seenInvites: Set /// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users. - @UserPreference - nonisolated(unsafe) var hasSeenNewSoundBanner: Bool + @UserPreference(defaultValue: true) + var hasSeenNewSoundBanner: Bool /// 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). let elementProAppStoreURL: URL = "https://apps.apple.com/app/element-pro-for-work/id6502951615" - @UserPreference - nonisolated(unsafe) var appAppearance: AppAppearance + @UserPreference(defaultValue: AppAppearance.system) + var appAppearance: AppAppearance // 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. let appLockPINCodeBlockList = ["0000", "1234"] /// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked). - @UserPreference - nonisolated(unsafe) var appLockNumberOfPINAttempts: Int + @UserPreference(defaultValue: 0) + var appLockNumberOfPINAttempts: Int // MARK: - Authentication @@ -281,34 +229,34 @@ final nonisolated class AppSettings: @unchecked Sendable { pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify") } - @UserPreference - nonisolated(unsafe) var enableNotifications: Bool + @UserPreference(defaultValue: true) + var enableNotifications: Bool - @UserPreference - nonisolated(unsafe) var enableInAppNotifications: Bool + @UserPreference(defaultValue: true) + var enableInAppNotifications: Bool - @UserPreference - nonisolated(unsafe) var hideQuietNotificationAlerts: Bool + @UserPreference(defaultValue: false) + var hideQuietNotificationAlerts: Bool /// Tag describing which set of device specific rules a pusher executes. @UserPreference - nonisolated(unsafe) var pusherProfileTag: String? + var pusherProfileTag: String? /// The device's last boot time as recorded by the NSE. @UserPreference - nonisolated(unsafe) var lastNotificationBootTime: TimeInterval? + var lastNotificationBootTime: TimeInterval? /// The sound played when delivering noisy notifications. If nil, use the ElementX default @UserPreference - nonisolated(unsafe) var selectedNotificationTone: NotificationTone? + var selectedNotificationTone: NotificationTone? // MARK: - Logging - @UserPreference - nonisolated(unsafe) var logLevel: LogLevel + @UserPreference(defaultValue: LogLevel.info) + var logLevel: LogLevel - @UserPreference - nonisolated(unsafe) var traceLogPacks: Set + @UserPreference(defaultValue: Set()) + var traceLogPacks: Set // MARK: - Bug report @@ -335,47 +283,47 @@ final nonisolated class AppSettings: @unchecked Sendable { } /// Whether the user has opted in to send analytics. - @UserPreference - nonisolated(unsafe) var analyticsConsentState: AnalyticsConsentState + @UserPreference(defaultValue: AnalyticsConsentState.unknown) + var analyticsConsentState: AnalyticsConsentState - @UserPreference - nonisolated(unsafe) var hasRunNotificationPermissionsOnboarding: Bool + @UserPreference(defaultValue: false) + var hasRunNotificationPermissionsOnboarding: Bool - @UserPreference - nonisolated(unsafe) var hasRunIdentityConfirmationOnboarding: Bool + @UserPreference(defaultValue: false) + var hasRunIdentityConfirmationOnboarding: Bool - @UserPreference - nonisolated(unsafe) var hasRequestedLocationAlwaysLocationAuthorization: Bool + @UserPreference(defaultValue: false) + var hasRequestedLocationAlwaysLocationAuthorization: Bool - @UserPreference - nonisolated(unsafe) var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji] + @UserPreference(defaultValue: [FrequentlyUsedEmoji]()) + var frequentlyUsedSystemEmojis: [FrequentlyUsedEmoji] // MARK: - Live Location - @UserPreference - nonisolated(unsafe) var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] + @UserPreference(key: "liveLocationSharingTimeoutDatesByRoomID", defaultValue: [String: LiveLocationSession]()) + var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] - @UserPreference - nonisolated(unsafe) var liveLocationMinimumDistanceUpdate: Int + @UserPreference(defaultValue: 10) + var liveLocationMinimumDistanceUpdate: Int - @UserPreference - nonisolated(unsafe) var liveLocationDisclaimerDisplayed: Bool + @UserPreference(defaultValue: false) + var liveLocationDisclaimerDisplayed: Bool // MARK: - Home Screen - @UserPreference - nonisolated(unsafe) var roomListActivityVisibility: RoomListActivityVisibility + @UserPreference(defaultValue: RoomListActivityVisibility.current) + var roomListActivityVisibility: RoomListActivityVisibility // MARK: - Room Screen - @UserPreference - nonisolated(unsafe) var viewSourceEnabled: Bool + @UserPreference(defaultValue: AppBuildType.current == .debug) + var viewSourceEnabled: Bool - @UserPreference - nonisolated(unsafe) var optimizeMediaUploads: Bool + @UserPreference(defaultValue: true) + var optimizeMediaUploads: Bool - @UserPreference - nonisolated(unsafe) var voiceMessagePlaybackSpeed: AudioPlaybackSpeed + @UserPreference(defaultValue: AudioPlaybackSpeed.default) + var voiceMessagePlaybackSpeed: AudioPlaybackSpeed /// 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. @@ -394,7 +342,7 @@ final nonisolated class AppSettings: @unchecked Sendable { let elementCallPosthogSentryDSN = "https://3bd2f95ba5554d4497da7153b552ffb5@sentry.tools.element.io/41" @UserPreference - nonisolated(unsafe) var elementCallBaseURLOverride: URL? + var elementCallBaseURLOverride: URL? // MARK: - Users @@ -416,100 +364,57 @@ final nonisolated class AppSettings: @unchecked Sendable { // MARK: - Presence - @UserPreference - nonisolated(unsafe) var sharePresence: Bool + @UserPreference(defaultValue: true) + var sharePresence: Bool // MARK: - Feature Flags /// Others - @UserPreference - nonisolated(unsafe) var fuzzyRoomListSearchEnabled: Bool + @UserPreference(defaultValue: false) + var fuzzyRoomListSearchEnabled: Bool - @UserPreference - nonisolated(unsafe) var lowPriorityFilterEnabled: Bool + @UserPreference(defaultValue: false) + 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 - nonisolated(unsafe) var enableOnlySignedDeviceIsolationMode: Bool + @UserPreference(defaultValue: false) + var enableOnlySignedDeviceIsolationMode: Bool - @UserPreference - nonisolated(unsafe) var knockingEnabled: Bool + @UserPreference(defaultValue: false) + var knockingEnabled: Bool - @UserPreference - nonisolated(unsafe) var threadsEnabled: Bool + @UserPreference(defaultValue: false) + var threadsEnabled: Bool - @UserPreference - nonisolated(unsafe) var roomThreadListEnabled: Bool + @UserPreference(defaultValue: false) + var roomThreadListEnabled: Bool - @UserPreference - nonisolated(unsafe) var globalSearchEnabled: Bool + @UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac) + var globalSearchEnabled: Bool - @UserPreference - nonisolated(unsafe) var focusEventOnNotificationTap: Bool + @UserPreference(defaultValue: false) + var focusEventOnNotificationTap: Bool - @UserPreference - nonisolated(unsafe) var linkPreviewsEnabled: Bool + @UserPreference(defaultValue: false) + var linkPreviewsEnabled: Bool - @UserPreference - nonisolated(unsafe) var jumpToReadMarkerEnabled: Bool + @UserPreference(defaultValue: false) + var jumpToReadMarkerEnabled: Bool - @UserPreference - nonisolated(unsafe) var linkNewDeviceEnabled: Bool + @UserPreference(defaultValue: false) + var linkNewDeviceEnabled: Bool - @UserPreference - nonisolated(unsafe) var automaticBackPaginationEnabled: Bool + @UserPreference(defaultValue: false) + var automaticBackPaginationEnabled: Bool - @UserPreference - nonisolated(unsafe) var clientPausingAndResumingEnabled: Bool + @UserPreference(defaultValue: AppBuildType.current != .release, volatile: true) + var clientPausingAndResumingEnabled: Bool - @UserPreference - nonisolated(unsafe) var developerOptionsEnabled: Bool + @UserPreference(defaultValue: AppBuildType.current != .release) + 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) - _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 { @@ -518,13 +423,3 @@ final nonisolated class AppSettings: @unchecked Sendable { } 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) - } -} diff --git a/ElementX/Sources/Application/Settings/UserPreference.swift b/ElementX/Sources/Application/Settings/UserPreference.swift index 53fea0b9f..11e4fbe61 100644 --- a/ElementX/Sources/Application/Settings/UserPreference.swift +++ b/ElementX/Sources/Application/Settings/UserPreference.swift @@ -6,123 +6,15 @@ // Please see LICENSE files in the repository root for full details. // -import Combine 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 { - 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 - private let defaultValue: T - private let subject: PassthroughSubject = .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, - 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 { - 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 = 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 -nonisolated protocol KeyedStorage { - associatedtype Value: Codable - - subscript(key: String) -> Value? { get set } -} - -/// An implementation of KeyedStorage on the UserDefaults. +/// Typed storage of a `Codable` value in `UserDefaults`, used by the `@UserPreference` macro. /// /// When used with a `Value` that conforms to `PlistRepresentable` the Codable encode/decode /// phase is skipped, and values are stored natively in the plist. -final nonisolated class UserDefaultsStorage: KeyedStorage { +nonisolated struct UserDefaultsStorage { private let userDefaults: UserDefaultsProtocol init(userDefaults: UserDefaultsProtocol) { @@ -140,7 +32,7 @@ final nonisolated class UserDefaultsStorage: KeyedStorage { return value } - set { + nonmutating set { if Value.self is PlistRepresentable.Type { encodePlistRepresentable(value: newValue, for: key) } else { @@ -198,8 +90,6 @@ nonisolated extension Optional: Nullable { } } -nonisolated extension Dictionary: KeyedStorage where Key == String, Value: Codable { } - // MARK: - PlistRepresentable /// A protocol to mark types as being plist compliant. diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 4760859c3..3072407d8 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -118,21 +118,21 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol .weakAssign(to: \.state.selectedRoomID, on: self) .store(in: &cancellables) - appSettings.$roomListActivityVisibility + appSettings.roomListActivityVisibilityPublisher .sink { [weak self] value in self?.state.roomListActivityVisibility = value self?.updateRooms() } .store(in: &cancellables) - appSettings.$seenInvites + appSettings.seenInvitesPublisher .removeDuplicates() .sink { [weak self] _ in self?.updateRooms() } .store(in: &cancellables) - appSettings.$hasSeenNewSoundBanner + appSettings.hasSeenNewSoundBannerPublisher .sink { [weak self] hasSeenNewSoundBanner in self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner } diff --git a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift index 2ecd1d3bf..276cef4ef 100644 --- a/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift +++ b/ElementX/Sources/Screens/LocationSharing/LocationSharingScreenViewModel.swift @@ -173,7 +173,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati } .store(in: &cancellables) - appSettings.$liveLocationSharingSessionsByRoomID + appSettings.liveLocationSharingSessionsByRoomIDPublisher .map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil } .removeDuplicates() .sink { [weak self] isSharingLiveLocationOnThisDevice in diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index a798bd300..428d18f61 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -163,11 +163,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Private private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher) { - appSettings.$roomThreadListEnabled + appSettings.roomThreadListEnabledPublisher .weakAssign(to: \.state.roomThreadListEnabled, on: self) .store(in: &cancellables) - appSettings.$liveLocationSharingSessionsByRoomID + appSettings.liveLocationSharingSessionsByRoomIDPublisher .receive(on: DispatchQueue.main) .sink { [weak self] sessionsByRoomID in guard let self else { return } diff --git a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift index 196e8a6ad..6bc9bc93e 100644 --- a/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift +++ b/ElementX/Sources/Screens/SecurityAndPrivacyScreen/SecurityAndPrivacyScreenViewModel.swift @@ -152,7 +152,7 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, .weakAssign(to: \.state.isSpace, on: self) .store(in: &cancellables) - appSettings.$knockingEnabled + appSettings.knockingEnabledPublisher .weakAssign(to: \.state.isKnockingEnabled, on: self) .store(in: &cancellables) } diff --git a/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenViewModel.swift index 909a0d1b3..3a04c855b 100644 --- a/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/AnalyticsSettingsScreen/AnalyticsSettingsScreenViewModel.swift @@ -23,7 +23,7 @@ class AnalyticsSettingsScreenViewModel: AnalyticsSettingsScreenViewModelType, An super.init(initialViewState: state) - appSettings.$analyticsConsentState + appSettings.analyticsConsentStatePublisher .map { $0 == .optedIn } .weakAssign(to: \.state.bindings.enableAnalytics, on: self) .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift index cff2956a6..04d1b47e0 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift @@ -48,11 +48,11 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac)) // Listen for changes to AppSettings. - appSettings.$enableNotifications + appSettings.enableNotificationsPublisher .weakAssign(to: \.state.bindings.enableNotifications, on: self) .store(in: &cancellables) - appSettings.$selectedNotificationTone + appSettings.selectedNotificationTonePublisher .map { $0 ?? NotificationToneManager.defaultElementXMessageTone } .weakAssign(to: \.state.selectedAlertTone, on: self) .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index abeda7424..478a1b028 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -33,11 +33,11 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic), mediaProvider: userSession.mediaProvider) - appSettings.$developerOptionsEnabled + appSettings.developerOptionsEnabledPublisher .weakAssign(to: \.state.showDeveloperOptions, on: self) .store(in: &cancellables) - appSettings.$linkNewDeviceEnabled + appSettings.linkNewDeviceEnabledPublisher .weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index de0b2d57c..ecac2cbb3 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -508,7 +508,7 @@ class TimelineInteractionHandler { duration: voiceMessageRoomTimelineItem.content.duration, waveform: voiceMessageRoomTimelineItem.content.waveform, playbackSpeed: appSettings.voiceMessagePlaybackSpeed, - playbackSpeedPublisher: appSettings.$voiceMessagePlaybackSpeed) + playbackSpeedPublisher: appSettings.voiceMessagePlaybackSpeedPublisher) mediaPlayerProvider.register(audioPlayerState: playerState) return playerState } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 62618d5ab..2a3c0ca17 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -556,19 +556,19 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } private func setupAppSettingsSubscriptions() { - appSettings.$sharePresence + appSettings.sharePresencePublisher .weakAssign(to: \.state.showReadReceipts, on: self) .store(in: &cancellables) - appSettings.$viewSourceEnabled + appSettings.viewSourceEnabledPublisher .weakAssign(to: \.state.isViewSourceEnabled, on: self) .store(in: &cancellables) - appSettings.$threadsEnabled + appSettings.threadsEnabledPublisher .weakAssign(to: \.state.areThreadsEnabled, on: self) .store(in: &cancellables) - appSettings.$jumpToReadMarkerEnabled + appSettings.jumpToReadMarkerEnabledPublisher .weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self) .store(in: &cancellables) diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index 777271929..2a55bd8a9 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -55,7 +55,7 @@ class AppLockService: AppLockServiceProtocol { } var numberOfPINAttempts: AnyPublisher { - appSettings.$appLockNumberOfPINAttempts + appSettings.appLockNumberOfPINAttemptsPublisher } init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) { diff --git a/ElementX/Sources/Services/Location/LiveLocationManager.swift b/ElementX/Sources/Services/Location/LiveLocationManager.swift index cc96eae46..250ede223 100644 --- a/ElementX/Sources/Services/Location/LiveLocationManager.swift +++ b/ElementX/Sources/Services/Location/LiveLocationManager.swift @@ -183,7 +183,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } .store(in: &cancellables) - appSettings.$liveLocationSharingSessionsByRoomID + appSettings.liveLocationSharingSessionsByRoomIDPublisher .removeDuplicates() .sink { [weak self] sessions in guard let self else { return } @@ -197,7 +197,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana } .store(in: &cancellables) - appSettings.$liveLocationMinimumDistanceUpdate + appSettings.liveLocationMinimumDistanceUpdatePublisher .removeDuplicates() .debounce(for: .seconds(1), scheduler: DispatchQueue.main) .sink { [weak self] newValue in diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 9eeed354b..44954d742 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -51,7 +51,7 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { MXLog.info("App setting 'enableNotifications' is '\(notificationsEnabled)'") // Listen for changes to AppSettings.enableNotifications - appSettings.$enableNotifications + appSettings.enableNotificationsPublisher .sink { [weak self] newValue in self?.enableNotifications(newValue) } diff --git a/UnitTests/Sources/LiveLocationManagerTests.swift b/UnitTests/Sources/LiveLocationManagerTests.swift index 6c1fd09f9..2b082881f 100644 --- a/UnitTests/Sources/LiveLocationManagerTests.swift +++ b/UnitTests/Sources/LiveLocationManagerTests.swift @@ -190,7 +190,7 @@ final class LiveLocationManagerTests { try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") #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)) try await deferred.fulfill() @@ -237,7 +237,7 @@ final class LiveLocationManagerTests { } 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)) try await deferred.fulfill() } diff --git a/UnitTests/Sources/UserPreferenceTests.swift b/UnitTests/Sources/UserPreferenceTests.swift index 7db3b42b1..b2615782f 100644 --- a/UnitTests/Sources/UserPreferenceTests.swift +++ b/UnitTests/Sources/UserPreferenceTests.swift @@ -6,8 +6,10 @@ // Please see LICENSE files in the repository root for full details. // +import Combine @testable import ElementX import Foundation +import Macros import Testing struct UserPreferenceTests { @@ -131,58 +133,29 @@ struct UserPreferenceTests { #expect(value.codable == 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 { - @UserPreference +private final class TestPreferences { + let store: UserDefaultsProtocol + + init(_ store: UserDefaultsProtocol) { + self.store = store + } + + @UserPreference(key: TestsKey.key1.rawValue, volatile: true) var volatileVar: String? - @UserPreference + @UserPreference(key: TestsKey.key2.rawValue) var plist: String? - @UserPreference + @UserPreference(key: TestsKey.key3.rawValue) var codable: CodableTestType? - @UserPreference + @UserPreference(key: TestsKey.key4.rawValue, volatile: true) var volatileCodable: CodableTestType? - @UserPreference + @UserPreference(key: TestsKey.key5.rawValue) 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 { @@ -190,13 +163,10 @@ private struct CodableTestType: Equatable, Codable { let b: [Int] } -private nonisolated struct TestsKey: PreferenceKeyable, RawRepresentable { - static let testKey = Self(rawValue: "testKey") - static let key1 = Self(rawValue: "foo.volatile") - static let key2 = Self(rawValue: "foo.plist") - static let key3 = Self(rawValue: "foo.codable") - static let key4 = Self(rawValue: "foo.volatile.codable") - static let key5 = Self(rawValue: "foo.plist.array") - - let rawValue: String +private enum TestsKey: String { + case key1 = "foo.volatile" + case key2 = "foo.plist" + case key3 = "foo.codable" + case key4 = "foo.volatile.codable" + case key5 = "foo.plist.array" }