From 66de86b5d2a5b31b1a9a49378f8b684b44cd9795 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 26 Jun 2026 14:08:38 +0100 Subject: [PATCH] Merge the UserDefaultsStorage helpers into UserDefaultsProtocol. --- .../UserPreferenceMacro.swift | 39 ++++--- .../UserPreferenceMacroTests.swift | 36 ++---- ElementX.xcodeproj/project.pbxproj | 8 -- .../Settings/UserDefaultsProtocol.swift | 63 ++++++++++ .../Application/Settings/UserPreference.swift | 109 ------------------ 5 files changed, 96 insertions(+), 159 deletions(-) delete mode 100644 ElementX/Sources/Application/Settings/UserPreference.swift diff --git a/BuildExtensions/Sources/MacrosImplementation/UserPreferenceMacro.swift b/BuildExtensions/Sources/MacrosImplementation/UserPreferenceMacro.swift index 6602cc906..14d7ce8ce 100644 --- a/BuildExtensions/Sources/MacrosImplementation/UserPreferenceMacro.swift +++ b/BuildExtensions/Sources/MacrosImplementation/UserPreferenceMacro.swift @@ -9,8 +9,8 @@ import SwiftSyntax import SwiftSyntaxMacros /// Implements `@UserPreference`. The generated property reads and writes through the enclosing -/// type's `store` (a `UserDefaultsProtocol`), wrapped in `UserDefaultsStorage` for the -/// encode/decode handling, and publishes changes via a generated per-preference subject. +/// type's `store` (a `UserDefaultsProtocol`), and publishes changes via a generated per-preference +/// subject. /// /// A `volatile` preference instead reads and writes through a generated in-memory store, so its /// value is never persisted and resets on each launch. @@ -33,7 +33,7 @@ public enum UserPreferenceMacro { let name: String let type: TypeSyntax let key: ExprSyntax - let defaultValue: ExprSyntax + let defaultValue: ExprSyntax? let isVolatile: Bool var subjectName: TokenSyntax { @@ -79,7 +79,7 @@ public enum UserPreferenceMacro { return Preference(name: name, type: type, key: key ?? "\(literal: name)", - defaultValue: defaultValue ?? "nil", + defaultValue: defaultValue, isVolatile: isVolatile) } } @@ -106,30 +106,34 @@ extension UserPreferenceMacro: AccessorMacro { ] } - let type = preference.type 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 [ """ get { - let storage = UserDefaultsStorage<\(type)>(userDefaults: store) - return storage[\(key)] ?? \(defaultValue) + \(readExpression(for: preference)) } """, """ set { - let storage = UserDefaultsStorage<\(type)>(userDefaults: store) - storage[\(key)] = newValue + store[\(key)] = 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 { @@ -143,7 +147,7 @@ extension UserPreferenceMacro: PeerMacro { var peers: [DeclSyntax] = [] 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>()") @@ -163,14 +167,13 @@ extension UserPreferenceMacro: PeerMacro { if preference.isVolatile { """ func \(preference.resetMethodName)() { - \(preference.volatileValueName) = \(preference.defaultValue) + \(preference.volatileValueName) = \(preference.defaultValue ?? "nil") } """ } else { """ func \(preference.resetMethodName)() { - let storage = UserDefaultsStorage<\(preference.type)>(userDefaults: store) - storage[\(preference.key)] = nil + store.removeObject(forKey: \(preference.key)) } """ } diff --git a/BuildExtensions/Tests/MacrosTests/UserPreferenceMacroTests.swift b/BuildExtensions/Tests/MacrosTests/UserPreferenceMacroTests.swift index d48dcf8d5..cc81057e1 100644 --- a/BuildExtensions/Tests/MacrosTests/UserPreferenceMacroTests.swift +++ b/BuildExtensions/Tests/MacrosTests/UserPreferenceMacroTests.swift @@ -21,12 +21,10 @@ final class UserPreferenceMacroTests: XCTestCase { """ var hasSeenNewSoundBanner: Bool { get { - let storage = UserDefaultsStorage(userDefaults: store) - return storage["hasSeenNewSoundBanner"] ?? true + store["hasSeenNewSoundBanner"] ?? true } set { - let storage = UserDefaultsStorage(userDefaults: store) - storage["hasSeenNewSoundBanner"] = newValue + store["hasSeenNewSoundBanner"] = newValue _hasSeenNewSoundBannerSubject.send(newValue) } } @@ -38,8 +36,7 @@ final class UserPreferenceMacroTests: XCTestCase { } func resetHasSeenNewSoundBanner() { - let storage = UserDefaultsStorage(userDefaults: store) - storage["hasSeenNewSoundBanner"] = nil + store.removeObject(forKey: "hasSeenNewSoundBanner") } """, macros: macros) @@ -54,12 +51,10 @@ final class UserPreferenceMacroTests: XCTestCase { """ var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] { get { - let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) - return storage["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]() + store["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]() } set { - let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) - storage["liveLocationSharingTimeoutDatesByRoomID"] = newValue + store["liveLocationSharingTimeoutDatesByRoomID"] = newValue _liveLocationSharingSessionsByRoomIDSubject.send(newValue) } } @@ -71,8 +66,7 @@ final class UserPreferenceMacroTests: XCTestCase { } func resetLiveLocationSharingSessionsByRoomID() { - let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store) - storage["liveLocationSharingTimeoutDatesByRoomID"] = nil + store.removeObject(forKey: "liveLocationSharingTimeoutDatesByRoomID") } """, macros: macros) @@ -87,12 +81,10 @@ final class UserPreferenceMacroTests: XCTestCase { """ var pusherProfileTag: String? { get { - let storage = UserDefaultsStorage(userDefaults: store) - return storage["pusherProfileTag"] ?? nil + store["pusherProfileTag"] } set { - let storage = UserDefaultsStorage(userDefaults: store) - storage["pusherProfileTag"] = newValue + store["pusherProfileTag"] = newValue _pusherProfileTagSubject.send(newValue) } } @@ -104,8 +96,7 @@ final class UserPreferenceMacroTests: XCTestCase { } func resetPusherProfileTag() { - let storage = UserDefaultsStorage(userDefaults: store) - storage["pusherProfileTag"] = nil + store.removeObject(forKey: "pusherProfileTag") } """, macros: macros) @@ -120,12 +111,10 @@ final class UserPreferenceMacroTests: XCTestCase { """ var viewSourceEnabled: Bool { get { - let storage = UserDefaultsStorage(userDefaults: store) - return storage["viewSourceEnabled"] ?? (Self.appBuildType == .debug) + store["viewSourceEnabled"] ?? (Self.appBuildType == .debug) } set { - let storage = UserDefaultsStorage(userDefaults: store) - storage["viewSourceEnabled"] = newValue + store["viewSourceEnabled"] = newValue _viewSourceEnabledSubject.send(newValue) } } @@ -137,8 +126,7 @@ final class UserPreferenceMacroTests: XCTestCase { } func resetViewSourceEnabled() { - let storage = UserDefaultsStorage(userDefaults: store) - storage["viewSourceEnabled"] = nil + store.removeObject(forKey: "viewSourceEnabled") } """, macros: macros) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 101bf7b8f..6535dc208 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -257,7 +257,6 @@ 28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */; }; 28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CABD320DE5566D133890B24 /* AccessibilityTestsAppCoordinator.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 */; }; 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.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 */; }; 6817EAD73DC1FFD8B943B5B9 /* HomeScreenRoomTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73587C2E3CF5998361AE516 /* HomeScreenRoomTests.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 */; }; 6851B077B4C913CC12DB6E77 /* AppLockFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE93F0CBF0D96B77111C413 /* AppLockFlowCoordinator.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 */; }; EF47D802A404A53F15D5D4B6 /* JoinRoomScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD7C0A2750998C2D77AD00F /* JoinRoomScreenViewModel.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 */; }; EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.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 = ""; }; 5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = ""; }; 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = ""; }; - 5E6DE144D887A254F4CAF203 /* UserPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreference.swift; sourceTree = ""; }; 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = ""; }; 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; @@ -6596,7 +6592,6 @@ A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */, 10D751743B20B314D84D79FE /* TrackedUserDefaults.swift */, 0CCA4547BE58E17899E95CDE /* UserDefaultsProtocol.swift */, - 5E6DE144D887A254F4CAF203 /* UserPreference.swift */, F2F56C9FFE8D55375CED9741 /* VolatileUserDefaults.swift */, ); path = Settings; @@ -7916,7 +7911,6 @@ D98B5EE8C4F5A2CE84687AE8 /* UTType.swift in Sources */, 34357B287357BC0B9715DD51 /* UserAgentBuilder.swift in Sources */, 1B1404DD4BB2C5079B074210 /* UserDefaultsProtocol.swift in Sources */, - 683154AD739245E56F37921E /* UserPreference.swift in Sources */, 28A494A66D0F21EC1EC4EB11 /* VolatileUserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -8131,7 +8125,6 @@ 26252AA9AED64010788F4C26 /* UIView.swift in Sources */, 66E9202BED03B5BB00E812A1 /* URL.swift in Sources */, 29E411E37D3FBF3899B3ED69 /* UserDefaultsProtocol.swift in Sources */, - EF79B9EFD094C17FBB4942C2 /* UserPreference.swift in Sources */, CDD76A7D42E1B47F7E423AA8 /* VolatileUserDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -9188,7 +9181,6 @@ F7BC744FFA7FE248FAE7F570 /* UserIndicatorToastView.swift in Sources */, A8DB299C5C891EDAE74A22F4 /* UserLocationCell.swift in Sources */, E3291AD16D7A5CB14781819C /* UserNotificationCenterProtocol.swift in Sources */, - 2932570AA418974979D16DED /* UserPreference.swift in Sources */, 80DEA2A4B20F9E279EAE6B2B /* UserProfile+Mock.swift in Sources */, ED90A59F068FD0CA27E602ED /* UserProfileListRow.swift in Sources */, E21FE4C5B614F311C0955859 /* UserProfileProxy.swift in Sources */, diff --git a/ElementX/Sources/Application/Settings/UserDefaultsProtocol.swift b/ElementX/Sources/Application/Settings/UserDefaultsProtocol.swift index 80544ac65..aff158662 100644 --- a/ElementX/Sources/Application/Settings/UserDefaultsProtocol.swift +++ b/ElementX/Sources/Application/Settings/UserDefaultsProtocol.swift @@ -16,3 +16,66 @@ public nonisolated protocol UserDefaultsProtocol: AnyObject { 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(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 { } diff --git a/ElementX/Sources/Application/Settings/UserPreference.swift b/ElementX/Sources/Application/Settings/UserPreference.swift deleted file mode 100644 index 11e4fbe61..000000000 --- a/ElementX/Sources/Application/Settings/UserPreference.swift +++ /dev/null @@ -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 { - 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 { }