Merge the UserDefaultsStorage helpers into UserDefaultsProtocol.

This commit is contained in:
Doug
2026-06-26 14:08:38 +01:00
committed by Doug
parent f05fd41c36
commit 66de86b5d2
5 changed files with 96 additions and 159 deletions
@@ -9,8 +9,8 @@ import SwiftSyntax
import SwiftSyntaxMacros import SwiftSyntaxMacros
/// Implements `@UserPreference`. The generated property reads and writes through the enclosing /// Implements `@UserPreference`. The generated property reads and writes through the enclosing
/// type's `store` (a `UserDefaultsProtocol`), wrapped in `UserDefaultsStorage` for the /// type's `store` (a `UserDefaultsProtocol`), and publishes changes via a generated per-preference
/// encode/decode handling, and publishes changes via a generated per-preference subject. /// subject.
/// ///
/// A `volatile` preference instead reads and writes through a generated in-memory store, so its /// A `volatile` preference instead reads and writes through a generated in-memory store, so its
/// value is never persisted and resets on each launch. /// value is never persisted and resets on each launch.
@@ -33,7 +33,7 @@ public enum UserPreferenceMacro {
let name: String let name: String
let type: TypeSyntax let type: TypeSyntax
let key: ExprSyntax let key: ExprSyntax
let defaultValue: ExprSyntax let defaultValue: ExprSyntax?
let isVolatile: Bool let isVolatile: Bool
var subjectName: TokenSyntax { var subjectName: TokenSyntax {
@@ -79,7 +79,7 @@ public enum UserPreferenceMacro {
return Preference(name: name, return Preference(name: name,
type: type, type: type,
key: key ?? "\(literal: name)", key: key ?? "\(literal: name)",
defaultValue: defaultValue ?? "nil", defaultValue: defaultValue,
isVolatile: isVolatile) isVolatile: isVolatile)
} }
} }
@@ -106,30 +106,34 @@ extension UserPreferenceMacro: AccessorMacro {
] ]
} }
let type = preference.type
let key = preference.key let key = preference.key
// Parenthesise a default that's a binary expression: `??` binds tighter than e.g. `==`,
// so `storage[key] ?? a == b` would otherwise parse as `(storage[key] ?? a) == b`.
let isBinaryExpression = preference.defaultValue.is(SequenceExprSyntax.self) || preference.defaultValue.is(InfixOperatorExprSyntax.self)
let defaultValue: ExprSyntax = isBinaryExpression ? "(\(preference.defaultValue))" : preference.defaultValue
return [ return [
""" """
get { get {
let storage = UserDefaultsStorage<\(type)>(userDefaults: store) \(readExpression(for: preference))
return storage[\(key)] ?? \(defaultValue)
} }
""", """,
""" """
set { set {
let storage = UserDefaultsStorage<\(type)>(userDefaults: store) store[\(key)] = newValue
storage[\(key)] = newValue
\(preference.subjectName).send(newValue) \(preference.subjectName).send(newValue)
} }
""" """
] ]
} }
/// The getter expression: `store[key]`, with the default appended when there is one. A default
/// that's a binary expression is parenthesised, as `??` binds tighter than e.g. `==`.
private static func readExpression(for preference: Preference) -> ExprSyntax {
guard let defaultValue = preference.defaultValue else {
return "store[\(preference.key)]"
}
let isBinaryExpression = defaultValue.is(SequenceExprSyntax.self) || defaultValue.is(InfixOperatorExprSyntax.self)
let wrappedDefault: ExprSyntax = isBinaryExpression ? "(\(defaultValue))" : defaultValue
return "store[\(preference.key)] ?? \(wrappedDefault)"
}
} }
extension UserPreferenceMacro: PeerMacro { extension UserPreferenceMacro: PeerMacro {
@@ -143,7 +147,7 @@ extension UserPreferenceMacro: PeerMacro {
var peers: [DeclSyntax] = [] var peers: [DeclSyntax] = []
if preference.isVolatile { if preference.isVolatile {
peers.append("private var \(preference.volatileValueName): \(type) = \(preference.defaultValue)") peers.append("private var \(preference.volatileValueName): \(type) = \(preference.defaultValue ?? "nil")")
} }
peers.append("private let \(preference.subjectName) = PassthroughSubject<\(type), Never>()") peers.append("private let \(preference.subjectName) = PassthroughSubject<\(type), Never>()")
@@ -163,14 +167,13 @@ extension UserPreferenceMacro: PeerMacro {
if preference.isVolatile { if preference.isVolatile {
""" """
func \(preference.resetMethodName)() { func \(preference.resetMethodName)() {
\(preference.volatileValueName) = \(preference.defaultValue) \(preference.volatileValueName) = \(preference.defaultValue ?? "nil")
} }
""" """
} else { } else {
""" """
func \(preference.resetMethodName)() { func \(preference.resetMethodName)() {
let storage = UserDefaultsStorage<\(preference.type)>(userDefaults: store) store.removeObject(forKey: \(preference.key))
storage[\(preference.key)] = nil
} }
""" """
} }
@@ -21,12 +21,10 @@ final class UserPreferenceMacroTests: XCTestCase {
""" """
var hasSeenNewSoundBanner: Bool { var hasSeenNewSoundBanner: Bool {
get { get {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store["hasSeenNewSoundBanner"] ?? true
return storage["hasSeenNewSoundBanner"] ?? true
} }
set { set {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store["hasSeenNewSoundBanner"] = newValue
storage["hasSeenNewSoundBanner"] = newValue
_hasSeenNewSoundBannerSubject.send(newValue) _hasSeenNewSoundBannerSubject.send(newValue)
} }
} }
@@ -38,8 +36,7 @@ final class UserPreferenceMacroTests: XCTestCase {
} }
func resetHasSeenNewSoundBanner() { func resetHasSeenNewSoundBanner() {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store.removeObject(forKey: "hasSeenNewSoundBanner")
storage["hasSeenNewSoundBanner"] = nil
} }
""", """,
macros: macros) macros: macros)
@@ -54,12 +51,10 @@ final class UserPreferenceMacroTests: XCTestCase {
""" """
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] { var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] {
get { get {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) store["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]()
return storage["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]()
} }
set { set {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) store["liveLocationSharingTimeoutDatesByRoomID"] = newValue
storage["liveLocationSharingTimeoutDatesByRoomID"] = newValue
_liveLocationSharingSessionsByRoomIDSubject.send(newValue) _liveLocationSharingSessionsByRoomIDSubject.send(newValue)
} }
} }
@@ -71,8 +66,7 @@ final class UserPreferenceMacroTests: XCTestCase {
} }
func resetLiveLocationSharingSessionsByRoomID() { func resetLiveLocationSharingSessionsByRoomID() {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) store.removeObject(forKey: "liveLocationSharingTimeoutDatesByRoomID")
storage["liveLocationSharingTimeoutDatesByRoomID"] = nil
} }
""", """,
macros: macros) macros: macros)
@@ -87,12 +81,10 @@ final class UserPreferenceMacroTests: XCTestCase {
""" """
var pusherProfileTag: String? { var pusherProfileTag: String? {
get { get {
let storage = UserDefaultsStorage<String?>(userDefaults: store) store["pusherProfileTag"]
return storage["pusherProfileTag"] ?? nil
} }
set { set {
let storage = UserDefaultsStorage<String?>(userDefaults: store) store["pusherProfileTag"] = newValue
storage["pusherProfileTag"] = newValue
_pusherProfileTagSubject.send(newValue) _pusherProfileTagSubject.send(newValue)
} }
} }
@@ -104,8 +96,7 @@ final class UserPreferenceMacroTests: XCTestCase {
} }
func resetPusherProfileTag() { func resetPusherProfileTag() {
let storage = UserDefaultsStorage<String?>(userDefaults: store) store.removeObject(forKey: "pusherProfileTag")
storage["pusherProfileTag"] = nil
} }
""", """,
macros: macros) macros: macros)
@@ -120,12 +111,10 @@ final class UserPreferenceMacroTests: XCTestCase {
""" """
var viewSourceEnabled: Bool { var viewSourceEnabled: Bool {
get { get {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store["viewSourceEnabled"] ?? (Self.appBuildType == .debug)
return storage["viewSourceEnabled"] ?? (Self.appBuildType == .debug)
} }
set { set {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store["viewSourceEnabled"] = newValue
storage["viewSourceEnabled"] = newValue
_viewSourceEnabledSubject.send(newValue) _viewSourceEnabledSubject.send(newValue)
} }
} }
@@ -137,8 +126,7 @@ final class UserPreferenceMacroTests: XCTestCase {
} }
func resetViewSourceEnabled() { func resetViewSourceEnabled() {
let storage = UserDefaultsStorage<Bool>(userDefaults: store) store.removeObject(forKey: "viewSourceEnabled")
storage["viewSourceEnabled"] = nil
} }
""", """,
macros: macros) macros: macros)
-8
View File
@@ -257,7 +257,6 @@
28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */; }; 28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */; };
28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */; }; 28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.swift */; };
291A3E0EECD496D0FCC2ADAA /* TimelineScrollButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */; }; 291A3E0EECD496D0FCC2ADAA /* TimelineScrollButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4E14E3688FF3532456A656 /* TimelineScrollButton.swift */; };
2932570AA418974979D16DED /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; };
29491EE7AE37E239E839C5A3 /* LocationSharingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEBF0E59F25E842EDB6FD11 /* LocationSharingScreenModels.swift */; }; 29491EE7AE37E239E839C5A3 /* LocationSharingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEBF0E59F25E842EDB6FD11 /* LocationSharingScreenModels.swift */; };
2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; }; 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; };
298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; }; 298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; };
@@ -628,7 +627,6 @@
67FEA7157FA1B39DFC533C78 /* LeaveSpaceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */; }; 67FEA7157FA1B39DFC533C78 /* LeaveSpaceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */; };
6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */; }; 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.swift */; };
68184EF36396424FE19A727D /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; 68184EF36396424FE19A727D /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; };
683154AD739245E56F37921E /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; };
6832733838C57A7D3FE8FEB5 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; }; 6832733838C57A7D3FE8FEB5 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 50009897F60FAE7D63EF5E5B /* Kingfisher */; };
6851B077B4C913CC12DB6E77 /* AppLockFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.swift */; }; 6851B077B4C913CC12DB6E77 /* AppLockFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.swift */; };
6885C3D26FC1026E07408D3C /* RoomListActivityVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */; }; 6885C3D26FC1026E07408D3C /* RoomListActivityVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */; };
@@ -1444,7 +1442,6 @@
EF1C1A5D212B14FFCE0E7C83 /* SpaceScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */; }; EF1C1A5D212B14FFCE0E7C83 /* SpaceScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094F6B21835890B470DF540C /* SpaceScreenCoordinator.swift */; };
EF47D802A404A53F15D5D4B6 /* JoinRoomScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD7C0A2750998C2D77AD00F /* JoinRoomScreenViewModel.swift */; }; EF47D802A404A53F15D5D4B6 /* JoinRoomScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD7C0A2750998C2D77AD00F /* JoinRoomScreenViewModel.swift */; };
EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; }; EF5009AC03212227131C8AF2 /* RoomNotificationSettingsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B5EA766E89FF1F87C3ACB /* RoomNotificationSettingsProxyProtocol.swift */; };
EF79B9EFD094C17FBB4942C2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; };
EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */; }; EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */; };
EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */; }; EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */; };
EFC4CCCF9D22FFA7D542693A /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; }; EFC4CCCF9D22FFA7D542693A /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; };
@@ -2187,7 +2184,6 @@
5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = "<group>"; }; 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = "<group>"; };
5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = "<group>"; }; 5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = "<group>"; };
5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = "<group>"; }; 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = "<group>"; };
5E6DE144D887A254F4CAF203 /* UserPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreference.swift; sourceTree = "<group>"; };
5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = "<group>"; }; 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = "<group>"; };
5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; }; 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; };
5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = "<group>"; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -6596,7 +6592,6 @@
A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */, A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */,
10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */, 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */,
0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */, 0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */,
5E6DE144D887A254F4CAF203 /* UserPreference.swift */,
F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */, F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */,
); );
path = Settings; path = Settings;
@@ -7916,7 +7911,6 @@
D98B5EE8C4F5A2CE84687AE8 /* UTType.swift in Sources */, D98B5EE8C4F5A2CE84687AE8 /* UTType.swift in Sources */,
34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */, 34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */,
1B1404DD4BB2C5079B074210 /* UserDefaultsProtocol.swift in Sources */, 1B1404DD4BB2C5079B074210 /* UserDefaultsProtocol.swift in Sources */,
683154AD739245E56F37921E /* UserPreference.swift in Sources */,
28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */, 28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -8131,7 +8125,6 @@
26252AA9AED64010788F4C26 /* UIView.swift in Sources */, 26252AA9AED64010788F4C26 /* UIView.swift in Sources */,
66E9202BED03B5BB00E812A1 /* URL.swift in Sources */, 66E9202BED03B5BB00E812A1 /* URL.swift in Sources */,
29E411E37D3FBF3899B3ED69 /* UserDefaultsProtocol.swift in Sources */, 29E411E37D3FBF3899B3ED69 /* UserDefaultsProtocol.swift in Sources */,
EF79B9EFD094C17FBB4942C2 /* UserPreference.swift in Sources */,
CDD76A7D42E1B47F7E423AA8 /* VolatileUserDefaults.swift in Sources */, CDD76A7D42E1B47F7E423AA8 /* VolatileUserDefaults.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -9188,7 +9181,6 @@
F7BC744FFA7FE248FAE7F570 /* UserIndicatorToastView.swift in Sources */, F7BC744FFA7FE248FAE7F570 /* UserIndicatorToastView.swift in Sources */,
A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */, A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */,
E3291AD16D7A5CB14781819C /* UserNotificationCenterProtocol.swift in Sources */, E3291AD16D7A5CB14781819C /* UserNotificationCenterProtocol.swift in Sources */,
2932570AA418974979D16DED /* UserPreference.swift in Sources */,
80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */, 80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */,
ED90A59F068FD0CA27E602ED /* UserProfileListRow.swift in Sources */, ED90A59F068FD0CA27E602ED /* UserProfileListRow.swift in Sources */,
E21FE4C5B614F311C0955859 /* UserProfileProxy.swift in Sources */, E21FE4C5B614F311C0955859 /* UserProfileProxy.swift in Sources */,
@@ -16,3 +16,66 @@ public nonisolated protocol UserDefaultsProtocol: AnyObject {
func reset() func reset()
} }
// MARK: - Codable handling
nonisolated extension UserDefaultsProtocol {
/// Reads and writes a `Codable` value stored under `key`, used by the `@UserPreference` macro.
///
/// When `Value` conforms to `PlistRepresentable` the Codable encode/decode phase is skipped and
/// the value is stored natively in the plist.
subscript<Value: Codable>(key: String) -> Value? {
get {
if Value.self is PlistRepresentable.Type {
object(forKey: key) as? Value
} else {
data(forKey: key).flatMap { try? JSONDecoder().decode(Value.self, from: $0) }
}
}
set {
// Detects correctly double optionals like this: String?? = .some(nil)
if newValue.isNil {
removeObject(forKey: key)
} else if Value.self is PlistRepresentable.Type {
set(newValue, forKey: key)
} else {
set(try? JSONEncoder().encode(newValue), forKey: key)
}
}
}
}
private nonisolated protocol Nullable {
var isNil: Bool { get }
}
nonisolated extension Optional: Nullable {
var isNil: Bool {
switch self {
case .none:
return true
case .some(let nullable as Nullable):
return nullable.isNil
case .some:
return false
}
}
}
// MARK: - PlistRepresentable
/// A protocol to mark types as being plist compliant.
/// The `UserDefaultsProtocol` storage subscript uses this to avoid encoding/decoding plist compliant values with Codable.
nonisolated protocol PlistRepresentable { }
extension Bool: PlistRepresentable { }
extension String: PlistRepresentable { }
extension Int: PlistRepresentable { }
extension Float: PlistRepresentable { }
extension Double: PlistRepresentable { }
extension Date: PlistRepresentable { }
extension Data: PlistRepresentable { }
extension Array: PlistRepresentable where Element: PlistRepresentable { }
extension Dictionary: PlistRepresentable where Key == String, Value: PlistRepresentable { }
extension Optional: PlistRepresentable where Wrapped: PlistRepresentable { }
@@ -1,109 +0,0 @@
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
// MARK: - Storage
/// 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.
nonisolated struct UserDefaultsStorage<Value: Codable> {
private let userDefaults: UserDefaultsProtocol
init(userDefaults: UserDefaultsProtocol) {
self.userDefaults = userDefaults
}
subscript(key: String) -> Value? {
get {
let value: Value?
if Value.self is PlistRepresentable.Type {
value = decodePlistRepresentableValue(for: key)
} else {
value = decodeValue(for: key)
}
return value
}
nonmutating set {
if Value.self is PlistRepresentable.Type {
encodePlistRepresentable(value: newValue, for: key)
} else {
encode(value: newValue, for: key)
}
}
}
private func decodeValue(for key: String) -> Value? {
userDefaults
.data(forKey: key)
.flatMap {
try? JSONDecoder().decode(Value.self, from: $0)
}
}
private func decodePlistRepresentableValue(for key: String) -> Value? {
userDefaults.object(forKey: key) as? Value
}
private func encode(value: Value?, for key: String) {
// Detects correctly double optionals like this: String?? = .some(nil)
if value.isNil {
userDefaults.removeObject(forKey: key)
} else {
let encodedValue = try? JSONEncoder().encode(value)
userDefaults.set(encodedValue, forKey: key)
}
}
private func encodePlistRepresentable(value: Value?, for key: String) {
// Detects correctly double optionals like this: String?? = .some(nil)
if value.isNil {
userDefaults.removeObject(forKey: key)
} else {
userDefaults.set(value, forKey: key)
}
}
}
private nonisolated protocol Nullable {
var isNil: Bool { get }
}
nonisolated extension Optional: Nullable {
var isNil: Bool {
switch self {
case .none:
return true
case .some(let nullable as Nullable):
return nullable.isNil
case .some:
return false
}
}
}
// MARK: - PlistRepresentable
/// A protocol to mark types as being plist compliant.
/// UserDefaultsStorage uses this protocol to avoid to encode/decode with Codable plist compliant values.
nonisolated protocol PlistRepresentable { }
extension Bool: PlistRepresentable { }
extension String: PlistRepresentable { }
extension Int: PlistRepresentable { }
extension Float: PlistRepresentable { }
extension Double: PlistRepresentable { }
extension Date: PlistRepresentable { }
extension Data: PlistRepresentable { }
extension Array: PlistRepresentable where Element: PlistRepresentable { }
extension Dictionary: PlistRepresentable where Key == String, Value: PlistRepresentable { }
extension Optional: PlistRepresentable where Wrapped: PlistRepresentable { }