mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Replace the UserPreference property wrapper with the new macro version.
This commit is contained in:
@@ -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<String>
|
||||
@UserPreference(defaultValue: 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.
|
||||
@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<TraceLogPack>
|
||||
@UserPreference(defaultValue: Set<TraceLogPack>())
|
||||
var traceLogPacks: Set<TraceLogPack>
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
|
||||
nonisolated protocol KeyedStorage<Value> {
|
||||
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<Value: Codable>: KeyedStorage {
|
||||
nonisolated struct UserDefaultsStorage<Value: Codable> {
|
||||
private let userDefaults: UserDefaultsProtocol
|
||||
|
||||
init(userDefaults: UserDefaultsProtocol) {
|
||||
@@ -140,7 +32,7 @@ final nonisolated class UserDefaultsStorage<Value: Codable>: 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.
|
||||
|
||||
Reference in New Issue
Block a user