diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 4fddbcb1860d7fa101314d05e152b839945bdc2b..62ab66473ebafaf99c5ce895be87c637e34f0226 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1044,7 +1044,6 @@ D02DEB36D32A72A1B365E452 /* SessionVerificationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */; }; D050D7756E92CA061ED0ABF0 /* SecureBackupLogoutConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E08B8A66948E9690F38B94 /* SecureBackupLogoutConfirmationScreenViewModelProtocol.swift */; }; D0A965852D6C04138FA55181 /* SecureBackupLogoutConfirmationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF239C619971FDE48132550 /* SecureBackupLogoutConfirmationScreenModels.swift */; }; - D104B27C5DA0626B41CE78D3 /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */; }; D10BA4F041DC58580A440A32 /* RoomRolesAndPermissionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B1DC3B3FB40A7F4AE9B7BF /* RoomRolesAndPermissionsScreen.swift */; }; D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; }; D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; }; @@ -3065,7 +3064,6 @@ isa = PBXGroup; children = ( 01C4C7DB37597D7D8379511A /* Assets.xcassets */, - D174C6E7DCA00AAFC0169925 /* ElementCall */, A0C06C0F6A8621B22BFAEB56 /* Localizations */, 8AEA6A91159FA0D3EAFCCB0D /* Sounds */, ); @@ -5502,13 +5500,6 @@ path = ShareExtension; sourceTree = "<group>"; }; - D174C6E7DCA00AAFC0169925 /* ElementCall */ = { - isa = PBXGroup; - children = ( - ); - path = ElementCall; - sourceTree = "<group>"; - }; D382E465AF067C1BF888BF8E /* View */ = { isa = PBXGroup; children = ( @@ -6816,7 +6807,6 @@ files = ( B8EC8A544162B0A41B9AB339 /* AppSettings.swift in Sources */, 2F2906AE9BC3D0E79A6F98F8 /* Bundle.swift in Sources */, - D104B27C5DA0626B41CE78D3 /* CurrentValuePublisher.swift in Sources */, F38D32C1B0232AAFE6A0822C /* ExtensionLogger.swift in Sources */, C022284E2774A5E1EF683B4D /* FileManager.swift in Sources */, 05FF0CD80EDAB3A7C0D4700A /* InfoPlistReader.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 071bea0d4059697bf98719ac92e266cdc03a1c5e..8779b2e8cfb35080dc3a386d665c3d1afbd6503a 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -55,6 +55,7 @@ final class AppSettings { } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier + private static var remoteSuiteName = "\(InfoPlistReader.main.appGroupIdentifier).remote" /// UserDefaults to be used on reads and writes. private static var store: UserDefaults! = UserDefaults(suiteName: suiteName) diff --git a/ElementX/Sources/Other/UserPreference.swift b/ElementX/Sources/Other/UserPreference.swift index 11d4e1a5bdf0c5e7fd88c073187631c544225ddf..b8a3690c91c91c74f92f86055e41bb1c617d5ca2 100644 --- a/ElementX/Sources/Other/UserPreference.swift +++ b/ElementX/Sources/Other/UserPreference.swift @@ -11,61 +11,76 @@ 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. -/// -/// - Note: This wrapper allows enforcing a default value through the `forceDefault` closure. @propertyWrapper final 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 defaultValue: T private let subject: PassthroughSubject<T, Never> = .init() - private var cancellable: AnyCancellable? + private let mode: Mode - /// A publisher that determines whether the default value is always being enforced. - let forceDefault: CurrentValuePublisher<Bool, Never> + // 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. + /// 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. + /// - 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: String, - defaultValue: @autoclosure @escaping () -> T, + defaultValue: T, keyedStorage: any KeyedStorage<T>, - forceDefault: CurrentValuePublisher<Bool, Never> = .init(.init(false))) { + mode: Mode) { self.key = key self.defaultValue = defaultValue self.keyedStorage = keyedStorage - self.forceDefault = forceDefault - - cancellable = forceDefault - .sink { [weak self] value in - guard value else { - return - } - // If we are now forcing the default value, we need to update the subject with the default value. - self?.subject.send(defaultValue()) - } + 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 { - guard !forceDefault.value else { - return defaultValue() + switch mode { + case .localOverRemote: + return keyedStorage[key] ?? keyedStorage[remoteKey] ?? defaultValue + case .remoteOverLocal: + return keyedStorage[remoteKey] ?? keyedStorage[key] ?? defaultValue } - return keyedStorage[key] ?? defaultValue() } set { - guard !forceDefault.value else { - return - } 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) @@ -77,11 +92,11 @@ final class UserPreference<T: Codable> { extension UserPreference { enum StorageType { - case userDefaults(UserDefaults = .standard) + case userDefaults(UserDefaults) case volatile } - convenience init(key: String, defaultValue: T, storageType: StorageType) { + convenience init(key: String, defaultValue: T, storageType: StorageType, mode: Mode = .localOverRemote) { let storage: any KeyedStorage<T> switch storageType { @@ -91,19 +106,19 @@ extension UserPreference { storage = [String: T]() } - self.init(key: key, defaultValue: defaultValue, keyedStorage: storage) + self.init(key: key, defaultValue: defaultValue, keyedStorage: storage, mode: mode) } - convenience init<R: RawRepresentable>(key: R, defaultValue: T, storageType: StorageType) where R.RawValue == String { - self.init(key: key.rawValue, defaultValue: defaultValue, storageType: storageType) + convenience init<R: RawRepresentable>(key: R, defaultValue: T, storageType: StorageType, mode: Mode = .localOverRemote) where R.RawValue == String { + self.init(key: key.rawValue, defaultValue: defaultValue, storageType: storageType, mode: mode) } - convenience init(key: String, storageType: StorageType) where T: ExpressibleByNilLiteral { - self.init(key: key, defaultValue: nil, storageType: storageType) + convenience init(key: String, storageType: StorageType, mode: Mode = .localOverRemote) where T: ExpressibleByNilLiteral { + self.init(key: key, defaultValue: nil, storageType: storageType, mode: mode) } - convenience init<R: RawRepresentable>(key: R, storageType: StorageType) where R: RawRepresentable, R.RawValue == String, T: ExpressibleByNilLiteral { - self.init(key: key.rawValue, storageType: storageType) + convenience init<R: RawRepresentable>(key: R, storageType: StorageType, mode: Mode = .localOverRemote) where R: RawRepresentable, R.RawValue == String, T: ExpressibleByNilLiteral { + self.init(key: key.rawValue, storageType: storageType, mode: mode) } } diff --git a/ShareExtension/SupportingFiles/target.yml b/ShareExtension/SupportingFiles/target.yml index 21f9e63c3673a2cdc4541b5d21d233bc286b4fa8..e30f067bda8e0b1c8f670b4ce99fabc4d9b507f6 100644 --- a/ShareExtension/SupportingFiles/target.yml +++ b/ShareExtension/SupportingFiles/target.yml @@ -91,4 +91,3 @@ targets: - path: ../../ElementX/Sources/Other/Logging - path: ../../ElementX/Sources/Other/UserPreference.swift - path: ../../ElementX/Sources/UITests/UITestsScreenIdentifier.swift - - path: ../../ElementX/Sources/Other/CurrentValuePublisher.swift diff --git a/UnitTests/Sources/UserPreferenceTests.swift b/UnitTests/Sources/UserPreferenceTests.swift index 4cd1b251b81f1548f147dc6d1e250bd911c8791e..11582875dd509ba85a1d25c6b476026d533e98ca 100644 --- a/UnitTests/Sources/UserPreferenceTests.swift +++ b/UnitTests/Sources/UserPreferenceTests.swift @@ -12,6 +12,7 @@ import XCTest final class UserPreferenceTests: XCTestCase { override func setUpWithError() throws { UserDefaults.testDefaults.removeVolatileDomain(forName: .userDefaultsSuiteName) + UserDefaults.testDefaults.removePersistentDomain(forName: .userDefaultsSuiteName) } func testStorePlistValue() throws { @@ -120,6 +121,29 @@ final class UserPreferenceTests: XCTestCase { XCTAssertNil(value.codable) XCTAssertNil(UserDefaults.testDefaults.data(forKey: .key3)) } + + func testLocalOverRemoteValue() { + @UserPreference(key: "testKey", defaultValue: "", storageType: .userDefaults(.testDefaults)) var preference + XCTAssertEqual(preference, "") + + _preference.remoteValue = "remote" + XCTAssertEqual(preference, "remote") + + preference = "local" + XCTAssertEqual(preference, "local") + } + + func testRemoteOverLocalValue() { + @UserPreference(key: "testKey", defaultValue: "", storageType: .userDefaults(.testDefaults), mode: .remoteOverLocal) var preference + XCTAssertEqual(preference, "") + + _preference.remoteValue = "remote" + XCTAssertEqual(preference, "remote") + + preference = "local" + XCTAssertEqual(preference, "remote") + XCTAssertTrue(_preference.isLockedToRemote) + } } private struct TestPreferences {