diff --git a/.gitignore b/.gitignore index 3b29812..ee8a18a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,112 @@ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc + + +##### +# OS X temporary files that should never be committed +.DS_Store +*.swp +# *.lock +*.profraw + +##### +# DotEnv files +.env + +#### +# Xcode temporary files that should never be committed +*~.nib + +#### +# Objective-C/Swift specific +*.hmap +*.ipa + +#### +# Xcode build files +DerivedData/ +build/ +Builds/ + +##### +# Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +#### +# Xcode 4 +xcuserdata +!xcschemes +# Xcode 4 +*.moved-aside + +#### +# XCode 4 workspaces - more detailed +!xcshareddata +!default.xcworkspace +*.xcworkspacedata + + +#### +# Xcode 5 +*.xccheckout +*.xcuserstate + +#### +# Xcode 7 +*.xcscmblueprint + +#### +# Other Xcode files +*.hmap +*.ipa + +#### +# Licences +*mono0926* + +#### +# Google Service plist file changes after every build +*GoogleService-Info.plist + +#### +# CocoaPods +Pods/ +# !Podfile +# !Podfile.lock + +#### +# Fastlane +# Temporary profiling data +/fastlane/report.xml +# Deliver temporary error output +/fastlane/Error*.png +# Deliver temporary preview output +/fastlane/Preview.html +# Snapshot generated screenshots +/fastlane/screenshots/*/*-portrait.png +/fastlane/screenshots/*/*-landscape.png +/fastlane/screenshots/screenshots.html +# Frameit generated screenshots +/fastlane/screenshots/*/*-portrait_framed.png +/fastlane/screenshots/*/*-landscape_framed.png + +#### +# rbenv +.rbenv-vars +default.profraw +.ruby-version + +# P8 Keys +goodrequest.json +*.p8 + +# Custom Scripts +logen2_token.json \ No newline at end of file diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GoodPersistence.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GoodPersistence.xcscheme new file mode 100644 index 0000000..bfb4345 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GoodPersistence.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/project.pbxproj b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/project.pbxproj index aa88ce8..e0017a5 100644 --- a/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/project.pbxproj +++ b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 5D0F02AA2BC6A7860052980E /* KeychainWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0F02A72BC6A0750052980E /* KeychainWrapper.swift */; }; + 5D0F02AB2BC6A7860052980E /* KeychainValueV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0F02A62BC6A0750052980E /* KeychainValueV1.swift */; }; + 5D0F02AC2BC6A7860052980E /* MajorMigrationTestsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0F02A82BC6A0750052980E /* MajorMigrationTestsV1.swift */; }; + 5D0F02AD2BC6A7860052980E /* KeychainItemAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0F02A52BC6A0750052980E /* KeychainItemAccessibility.swift */; }; + 5D1DFC942BC8237E00E42D59 /* KeychainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1DFC922BC8234100E42D59 /* KeychainTests.swift */; }; 5D4A95AA299C15E000DFAEAE /* GoodPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 5D4A95A9299C15E000DFAEAE /* GoodPersistence */; }; EA751BB429968D87004016E1 /* MultiLineLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA751BB329968D87004016E1 /* MultiLineLabel.swift */; }; EA751BBD29969A9C004016E1 /* UINavigationBarExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA751BBC29969A9C004016E1 /* UINavigationBarExtensions.swift */; }; @@ -34,7 +39,23 @@ EACEC4AA2996383D008242AA /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACEC4A92996383D008242AA /* Constants.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 5D0F02A02BC6A06C0052980E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EACEC4522995B363008242AA /* Project object */; + proxyType = 1; + remoteGlobalIDString = EACEC4592995B363008242AA; + remoteInfo = "GoodPersistence-Sample"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 5D0F029C2BC6A06C0052980E /* GoodPersistenceKeychainTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GoodPersistenceKeychainTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5D0F02A52BC6A0750052980E /* KeychainItemAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItemAccessibility.swift; sourceTree = ""; }; + 5D0F02A62BC6A0750052980E /* KeychainValueV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainValueV1.swift; sourceTree = ""; }; + 5D0F02A72BC6A0750052980E /* KeychainWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWrapper.swift; sourceTree = ""; }; + 5D0F02A82BC6A0750052980E /* MajorMigrationTestsV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MajorMigrationTestsV1.swift; sourceTree = ""; }; + 5D1DFC922BC8234100E42D59 /* KeychainTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainTests.swift; sourceTree = ""; }; 5D4A95A8299C15C900DFAEAE /* GoodPersistence */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GoodPersistence; path = ..; sourceTree = ""; }; EA751BB329968D87004016E1 /* MultiLineLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiLineLabel.swift; sourceTree = ""; }; EA751BBC29969A9C004016E1 /* UINavigationBarExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationBarExtensions.swift; sourceTree = ""; }; @@ -65,6 +86,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 5D0F02992BC6A06C0052980E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EACEC4572995B363008242AA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -76,6 +104,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5D0F029D2BC6A06C0052980E /* GoodPersistenceKeychainTests */ = { + isa = PBXGroup; + children = ( + 5D1DFC922BC8234100E42D59 /* KeychainTests.swift */, + 5D0F02A92BC6A0750052980E /* MajorMigrationTestsV1 */, + ); + path = GoodPersistenceKeychainTests; + sourceTree = ""; + }; + 5D0F02A92BC6A0750052980E /* MajorMigrationTestsV1 */ = { + isa = PBXGroup; + children = ( + 5D0F02A52BC6A0750052980E /* KeychainItemAccessibility.swift */, + 5D0F02A62BC6A0750052980E /* KeychainValueV1.swift */, + 5D0F02A72BC6A0750052980E /* KeychainWrapper.swift */, + 5D0F02A82BC6A0750052980E /* MajorMigrationTestsV1.swift */, + ); + path = MajorMigrationTestsV1; + sourceTree = ""; + }; EA751BB529968DAC004016E1 /* UILabel */ = { isa = PBXGroup; children = ( @@ -90,6 +138,7 @@ 5D4A95A8299C15C900DFAEAE /* GoodPersistence */, EACEC45C2995B363008242AA /* GoodPersistence-Sample */, EACEC4A52995CCB3008242AA /* Resources */, + 5D0F029D2BC6A06C0052980E /* GoodPersistenceKeychainTests */, EACEC45B2995B363008242AA /* Products */, EACEC4742995B3BC008242AA /* Frameworks */, ); @@ -99,6 +148,7 @@ isa = PBXGroup; children = ( EACEC45A2995B363008242AA /* GoodPersistence-Sample.app */, + 5D0F029C2BC6A06C0052980E /* GoodPersistenceKeychainTests.xctest */, ); name = Products; sourceTree = ""; @@ -256,6 +306,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 5D0F029B2BC6A06C0052980E /* GoodPersistenceKeychainTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5D0F02A42BC6A06C0052980E /* Build configuration list for PBXNativeTarget "GoodPersistenceKeychainTests" */; + buildPhases = ( + 5D0F02982BC6A06C0052980E /* Sources */, + 5D0F02992BC6A06C0052980E /* Frameworks */, + 5D0F029A2BC6A06C0052980E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5D0F02A12BC6A06C0052980E /* PBXTargetDependency */, + ); + name = GoodPersistenceKeychainTests; + productName = GoodPersistenceKeychainTests; + productReference = 5D0F029C2BC6A06C0052980E /* GoodPersistenceKeychainTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; EACEC4592995B363008242AA /* GoodPersistence-Sample */ = { isa = PBXNativeTarget; buildConfigurationList = EACEC4702995B365008242AA /* Build configuration list for PBXNativeTarget "GoodPersistence-Sample" */; @@ -283,9 +351,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1530; LastUpgradeCheck = 1520; TargetAttributes = { + 5D0F029B2BC6A06C0052980E = { + CreatedOnToolsVersion = 15.3; + TestTargetID = EACEC4592995B363008242AA; + }; EACEC4592995B363008242AA = { CreatedOnToolsVersion = 14.1; }; @@ -305,11 +377,19 @@ projectRoot = ""; targets = ( EACEC4592995B363008242AA /* GoodPersistence-Sample */, + 5D0F029B2BC6A06C0052980E /* GoodPersistenceKeychainTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 5D0F029A2BC6A06C0052980E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EACEC4582995B363008242AA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -323,6 +403,18 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 5D0F02982BC6A06C0052980E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D0F02AA2BC6A7860052980E /* KeychainWrapper.swift in Sources */, + 5D0F02AB2BC6A7860052980E /* KeychainValueV1.swift in Sources */, + 5D0F02AC2BC6A7860052980E /* MajorMigrationTestsV1.swift in Sources */, + 5D0F02AD2BC6A7860052980E /* KeychainItemAccessibility.swift in Sources */, + 5D1DFC942BC8237E00E42D59 /* KeychainTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; EACEC4562995B363008242AA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -353,6 +445,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 5D0F02A12BC6A06C0052980E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EACEC4592995B363008242AA /* GoodPersistence-Sample */; + targetProxy = 5D0F02A02BC6A06C0052980E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ EACEC46A2995B365008242AA /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; @@ -365,6 +465,51 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 5D0F02A22BC6A06C0052980E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.homescan.dev.GoodPersistenceKeychainTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GoodPersistence-Sample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GoodPersistence-Sample"; + }; + name = Debug; + }; + 5D0F02A32BC6A06C0052980E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = FFZN8CA2AB; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.homescan.dev.GoodPersistenceKeychainTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GoodPersistence-Sample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GoodPersistence-Sample"; + }; + name = Release; + }; EACEC46E2995B365008242AA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -416,7 +561,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -471,7 +616,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -486,6 +631,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "GoodPersistence-Sample/GoodPersistence-Sample.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FFZN8CA2AB; @@ -515,6 +661,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "GoodPersistence-Sample/GoodPersistence-Sample.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FFZN8CA2AB; @@ -542,6 +689,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 5D0F02A42BC6A06C0052980E /* Build configuration list for PBXNativeTarget "GoodPersistenceKeychainTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5D0F02A22BC6A06C0052980E /* Debug */, + 5D0F02A32BC6A06C0052980E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; EACEC4552995B363008242AA /* Build configuration list for PBXProject "GoodPersistence-Sample" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistence-Sample.xcscheme b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistence-Sample.xcscheme new file mode 100644 index 0000000..6b9a388 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistence-Sample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistenceKeychainTests.xcscheme b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistenceKeychainTests.xcscheme new file mode 100644 index 0000000..ce64928 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistence-Sample.xcodeproj/xcshareddata/xcschemes/GoodPersistenceKeychainTests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodPersistence-Sample/GoodPersistence-Sample/Application/AppDelegate.swift b/GoodPersistence-Sample/GoodPersistence-Sample/Application/AppDelegate.swift index 98543f1..0fa2c74 100644 --- a/GoodPersistence-Sample/GoodPersistence-Sample/Application/AppDelegate.swift +++ b/GoodPersistence-Sample/GoodPersistence-Sample/Application/AppDelegate.swift @@ -6,6 +6,7 @@ // import UIKit +import GoodPersistence @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -20,6 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UINavigationBar.configureAppearance() + GoodPersistence.Configuration.configure(monitors: [LoggingPersistenceMonitor(logger: OSLogLogger())]) AppCoordinator(window: window, di: DI()).start() return true diff --git a/GoodPersistence-Sample/GoodPersistence-Sample/GoodPersistence-Sample.entitlements b/GoodPersistence-Sample/GoodPersistence-Sample/GoodPersistence-Sample.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistence-Sample/GoodPersistence-Sample.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/GoodPersistence-Sample/GoodPersistence-Sample/Managers/CacheManager/CacheManagerType.swift b/GoodPersistence-Sample/GoodPersistence-Sample/Managers/CacheManager/CacheManagerType.swift index 60f8371..456ae54 100644 --- a/GoodPersistence-Sample/GoodPersistence-Sample/Managers/CacheManager/CacheManagerType.swift +++ b/GoodPersistence-Sample/GoodPersistence-Sample/Managers/CacheManager/CacheManagerType.swift @@ -16,7 +16,7 @@ protocol CacheManagerType: AnyObject { var savedTimeKeychain: String { get } - var savedTimeKeychainPublisher: AnyPublisher { get } + var savedTimeKeychainPublisher: AnyPublisher { get } var savedNumberKeychain: Int { get set } diff --git a/GoodPersistence-Sample/GoodPersistence-Sample/Screens/Home/HomeViewModel.swift b/GoodPersistence-Sample/GoodPersistence-Sample/Screens/Home/HomeViewModel.swift index e8f9669..055f5b8 100644 --- a/GoodPersistence-Sample/GoodPersistence-Sample/Screens/Home/HomeViewModel.swift +++ b/GoodPersistence-Sample/GoodPersistence-Sample/Screens/Home/HomeViewModel.swift @@ -7,6 +7,7 @@ import Combine import UIKit +import GoodPersistence final class HomeViewModel { diff --git a/Tests/GoodPersistenceTests/KeychainTests.swift b/GoodPersistence-Sample/GoodPersistenceKeychainTests/KeychainTests.swift similarity index 100% rename from Tests/GoodPersistenceTests/KeychainTests.swift rename to GoodPersistence-Sample/GoodPersistenceKeychainTests/KeychainTests.swift diff --git a/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainItemAccessibility.swift b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainItemAccessibility.swift new file mode 100644 index 0000000..f879242 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainItemAccessibility.swift @@ -0,0 +1,95 @@ +// +// KeychainItemAccessibility.swift +// +// +// Created by Andrej Jasso on 10/04/2024. +// + +import Foundation + +protocol KeychainAttrRepresentable { + var keychainAttrValue: CFString { get } +} + +// MARK: - KeychainItemAccessibility + +/// Represents level of accessibility for data stored in the keychain. +public enum KeychainItemAccessibility { + + case always + /** + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. + */ + @available(iOS 4, *) + case afterFirstUnlock + + /** + The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. + + After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + */ + @available(iOS 4, *) + case afterFirstUnlockThisDeviceOnly + + /** + The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. + + This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. + */ + @available(iOS 8, *) + case whenPasscodeSetThisDeviceOnly + + /** + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. + + This is the default value for keychain items added without explicitly setting an accessibility constant. + */ + @available(iOS 4, *) + case whenUnlocked + + /** + The data in the keychain item can be accessed only while the device is unlocked by the user. + + This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. + */ + @available(iOS 4, *) + case whenUnlockedThisDeviceOnly + + /// Returns the KeychainItemAccessibility for the given keychain attribute value. + /// + /// - Parameter keychainAttrValue: The keychain attribute value for which to return the corresponding KeychainItemAccessibility. + /// - Returns: The KeychainItemAccessibility for the given attribute value, or nil if no match is found. + static func accessibilityForAttributeValue(_ keychainAttrValue: CFString) -> KeychainItemAccessibility? { + for (key, value) in keychainItemAccessibilityLookup { + if value == keychainAttrValue { + return key + } + } + + return nil + } +} + +private let keychainItemAccessibilityLookup: [KeychainItemAccessibility:CFString] = { + var lookup: [KeychainItemAccessibility:CFString] = [ + .afterFirstUnlock: kSecAttrAccessibleAfterFirstUnlock, + .afterFirstUnlockThisDeviceOnly: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + .whenUnlocked: kSecAttrAccessibleWhenUnlocked, + .whenUnlockedThisDeviceOnly: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + .always: kSecAttrAccessibleAlways + ] + + return lookup +}() + +extension KeychainItemAccessibility : KeychainAttrRepresentable { + + internal var keychainAttrValue: CFString { + return keychainItemAccessibilityLookup[self]! + } + +} diff --git a/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainValueV1.swift b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainValueV1.swift new file mode 100644 index 0000000..2ad157e --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainValueV1.swift @@ -0,0 +1,86 @@ +// +// KeychainValue.swift +// +// +// Created by Andrej Jasso on 10/04/2024. +// + +import Foundation +import Combine +import CombineExt + +/// The KeychainValue wraps a value of any type that conforms to the Codable protocol, in order to store it in the Keychain +@available(iOS 13.0, *) +@propertyWrapper +public class KeychainValueV1 { + + /// It wraps a value of any type that conforms to the Codable protocol, in order to store it in a Keychain. + /// - Parameters: + /// - value: A value of any type that conforms to the Codable protocol. + private struct Wrapper: Codable { + + let value: T + + } + + private let subject: PassthroughSubject = PassthroughSubject() + private let key: String + private let defaultValue: T + private let accessibility: KeychainItemAccessibility? + + /// Initializes a KeychainValue instance with a given key, default value, and accessibility. + /// - Parameters: + /// - key: The key for the Keychain item + /// - defaultValue: The default value for the Keychain item + /// - accessibility: The accessibility level for the Keychain item. The default value is nil. + public init(_ key: String, defaultValue: T, accessibility: KeychainItemAccessibility? = nil) { + self.key = key + self.defaultValue = defaultValue + self.accessibility = accessibility + } + + /// Provide the wrapped value to the user which is retrieved from Keychain or the default value + public var wrappedValue: T { + get { + // Retrieve data from Keychain using the key and accessibility if specified + guard let data = KeychainWrapper.standard.data( + forKey: key, + withAccessibility: accessibility + ) else { + // Return default value if data cannot be retrieved from Keychain + return defaultValue + } + + // Decode the data and get the value, or return default value if decoding fails + let value = (try? PropertyListDecoder().decode(Wrapper.self, from: data))?.value ?? defaultValue + + return value + } + + set(newValue) { + // Wrap the new value in a Wrapper structure + let wrapper = Wrapper(value: newValue) + + // Encode the wrapper and set the data in Keychain using the key and accessibility if specified + guard let data = try? PropertyListEncoder().encode(wrapper) else { + // If encoding fails, remove the object from Keychain + KeychainWrapper.standard.removeObject(forKey: key) + return + } + KeychainWrapper.standard.set(data, forKey: key, withAccessibility: accessibility) + + // Send the new value through the subject + subject.send(newValue) + } + } + + /// The publisher property provides an AnyPublisher that sends the current value of wrappedValue, followed by any future changes. + public lazy var publisher: AnyPublisher = { + Deferred { + self.subject + .prepend(self.wrappedValue) + .share(replay: 1) + }.eraseToAnyPublisher() + }() + +} diff --git a/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainWrapper.swift b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainWrapper.swift new file mode 100644 index 0000000..4238da5 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/KeychainWrapper.swift @@ -0,0 +1,503 @@ +// +// KeychainWrapper.swift +// +// +// Created by Andrej Jasso on 10/04/2024. +// + +import Foundation + +private let SecMatchLimit: String! = kSecMatchLimit as String +private let SecReturnData: String! = kSecReturnData as String +private let SecReturnPersistentRef: String! = kSecReturnPersistentRef as String +private let SecValueData: String! = kSecValueData as String +private let SecAttrAccessible: String! = kSecAttrAccessible as String +private let SecClass: String! = kSecClass as String +private let SecAttrService: String! = kSecAttrService as String +private let SecAttrGeneric: String! = kSecAttrGeneric as String +private let SecAttrAccount: String! = kSecAttrAccount as String +private let SecAttrAccessGroup: String! = kSecAttrAccessGroup as String +private let SecReturnAttributes: String = kSecReturnAttributes as String + +/// KeychainWrapper is a class to help make Keychain access in Swift more straightforward. It is designed to make accessing the Keychain services more like using NSUserDefaults, which is much more familiar to people. +@available(OSX 10.13, *) +open class KeychainWrapper { + + @available(*, deprecated, message: "KeychainWrapper.defaultKeychainWrapper is deprecated, use KeychainWrapper.standard instead") + public static let defaultKeychainWrapper = KeychainWrapper.standard + + /// Default keychain wrapper access + public static let standard = KeychainWrapper() + + /// ServiceName is used for the kSecAttrService property to uniquely identify this keychain accessor. If no service name is specified, KeychainWrapper will default to using the bundleIdentifier. + private (set) public var serviceName: String + + /// AccessGroup is used for the kSecAttrAccessGroup property to identify which Keychain Access Group this entry belongs to. This allows you to use the KeychainWrapper with shared keychain access between different applications. + private (set) public var accessGroup: String? + + /// A private static constant defaultServiceName that returns the bundle identifier of the main bundle or "SwiftKeychainWrapper". + private static let defaultServiceName: String = { + return Bundle.main.bundleIdentifier ?? "SwiftKeychainWrapper" + }() + + private convenience init() { + self.init(serviceName: KeychainWrapper.defaultServiceName) + } + + /// Create a custom instance of KeychainWrapper with a custom Service Name and optional custom access group. + /// - Parameters: + /// - serviceName: The ServiceName for this instance. Used to uniquely identify all keys stored using this keychain wrapper instance. + /// - accessGroup: Optional unique AccessGroup for this instance. Use a matching AccessGroup between applications to allow shared keychain access. + public init(serviceName: String, accessGroup: String? = nil) { + self.serviceName = serviceName + self.accessGroup = accessGroup + } + + // MARK:- Public Methods + + /// Checks if keychain data exists for a specified key. + /// - Parameters: + /// - key: The key to check for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: True if a value exists for the key. False otherwise. + open func hasValue(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Bool { + if let _ = data(forKey: key, withAccessibility: accessibility) { + return true + } else { + return false + } + } + + /// Retrieves the `KeychainItemAccessibility` of a key stored in the Keychain. + /// - Parameter key: the key for which the accessibility should be retrieved + /// - Returns: accessibility of the given key stored in the Keychain, or nil if its not present + open func accessibilityOfKey(_ key: String) -> KeychainItemAccessibility? { + var keychainQueryDictionary = setupKeychainQueryDictionary(forKey: key) + + // Remove accessibility attribute + keychainQueryDictionary.removeValue(forKey: SecAttrAccessible) + + // Limit search results to one + keychainQueryDictionary[SecMatchLimit] = kSecMatchLimitOne + + // Specify we want SecAttrAccessible returned + keychainQueryDictionary[SecReturnAttributes] = kCFBooleanTrue + + // Search + var result: AnyObject? + let status = SecItemCopyMatching(keychainQueryDictionary as CFDictionary, &result) + + guard status == noErr, let resultsDictionary = result as? [String:AnyObject], let accessibilityAttrValue = resultsDictionary[SecAttrAccessible] as? String else { + return nil + } + + return KeychainItemAccessibility.accessibilityForAttributeValue(accessibilityAttrValue as CFString) + } + + /// Get the keys of all keychain entries matching the current ServiceName and AccessGroup if one is set. + open func allKeys() -> Set { + var keychainQueryDictionary: [String:Any] = [ + SecClass: kSecClassGenericPassword, + SecAttrService: serviceName, + SecReturnAttributes: kCFBooleanTrue!, + SecMatchLimit: kSecMatchLimitAll, + String(kSecAttrSynchronizable): kCFBooleanTrue! + ] + + if let accessGroup = self.accessGroup { + keychainQueryDictionary[SecAttrAccessGroup] = accessGroup + } + + var result: AnyObject? + let status = SecItemCopyMatching(keychainQueryDictionary as CFDictionary, &result) + guard status == errSecSuccess else { return [] } + + var keys = Set() + if let results = result as? [[AnyHashable: Any]] { + for attributes in results { + if let accountData = attributes[SecAttrAccount] as? Data, + let account = String(data: accountData, encoding: String.Encoding.utf8) { + keys.insert(account) + } + } + } + return keys + } + + // MARK: Public Getters + + /// Returns an Int value for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The Integer associated with the key if it exists. If no data exists, or the data found cannot be encoded as a NSNumber, returns nil. + open func integer(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Int? { + guard let numberValue = object(forKey: key, withAccessibility: accessibility) as? NSNumber else { + return nil + } + + return numberValue.intValue + } + + /// Returns a Float value for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The Float associated with the key if it exists. If no data exists, or the data found cannot be encoded as a NSNumber, returns nil. + open func float(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Float? { + guard let numberValue = object(forKey: key, withAccessibility: accessibility) as? NSNumber else { + return nil + } + + return numberValue.floatValue + } + + /// Returns a Double value for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The Double associated with the key if it exists. If no data exists, or the data found cannot be encoded as a NSNumber, returns nil. + open func double(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Double? { + guard let numberValue = object(forKey: key, withAccessibility: accessibility) as? NSNumber else { + return nil + } + + return numberValue.doubleValue + } + + /// Returns a Bool value for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The Bool associated with the key if it exists. If no data exists, or the data found cannot be encoded as a NSNumber, returns nil. + open func bool(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Bool? { + guard let numberValue = object(forKey: key, withAccessibility: accessibility) as? NSNumber else { + return nil + } + + return numberValue.boolValue + } + + /// Returns a string value for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The String associated with the key if it exists. If no data exists, or the data found cannot be encoded as a string, returns nil. + open func string(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> String? { + guard let keychainData = data(forKey: key, withAccessibility: accessibility) else { + return nil + } + + return String(data: keychainData, encoding: String.Encoding.utf8) as String? + } + + /// Returns an object that conforms to NSCoding for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The decoded object associated with the key if it exists. If no data exists, or the data found cannot be decoded, returns nil. + open func object( + forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> NSCoding? { + guard let keychainData = data(forKey: key, withAccessibility: accessibility) else { + return nil + } + + return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(keychainData) as? NSCoding + } + + /// Returns a Data object for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The Data object associated with the key if it exists. If no data exists, returns nil. + open func data(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Data? { + var keychainQueryDictionary = setupKeychainQueryDictionary(forKey: key, withAccessibility: accessibility) + + // Limit search results to one + keychainQueryDictionary[SecMatchLimit] = kSecMatchLimitOne + + // Specify we want Data/CFData returned + keychainQueryDictionary[SecReturnData] = kCFBooleanTrue + + print("OLD KEYCHAIN",keychainQueryDictionary) + keychainQueryDictionary.forEach { + print($0.key, String(describing: $0.value)) + } + // Search + var result: AnyObject? + let status = SecItemCopyMatching(keychainQueryDictionary as CFDictionary, &result) + + return status == noErr ? result as? Data : nil + } + + /// Returns a persistent data reference object for a specified key. + /// - Parameters: + /// - key: The key to lookup data for. + /// - accessibility: Optional accessibility to use when retrieving the keychain item. + /// - Returns: The persistent data reference object associated with the key if it exists. If no data exists, returns nil. + open func dataRef(forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil) -> Data? { + var keychainQueryDictionary = setupKeychainQueryDictionary(forKey: key, withAccessibility: accessibility) + + // Limit search results to one + keychainQueryDictionary[SecMatchLimit] = kSecMatchLimitOne + + // Specify we want persistent Data/CFData reference returned + keychainQueryDictionary[SecReturnPersistentRef] = kCFBooleanTrue + + // Search + var result: AnyObject? + let status = SecItemCopyMatching(keychainQueryDictionary as CFDictionary, &result) + + return status == noErr ? result as? Data : nil + } + + // MARK: Public Setters + + /// Save an Integer value to the keychain associated with a specified key. If an object already exists for the given key, the object will be overwritten with the new value. + /// + /// - parameter value: The Integer value to save. + /// - parameter forKey: The key to save the Integer under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: Int, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + return set(NSNumber(value: value), forKey: key, withAccessibility: accessibility) + } + + /// Save a Float value to the keychain associated with a specified key. If an object already exists for the given key, the object will be overwritten with the new value. + /// + /// - parameter value: The Float value to save. + /// - parameter forKey: The key to save the Float under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: Float, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + return set(NSNumber(value: value), forKey: key, withAccessibility: accessibility) + } + + /// Save a Double value to the keychain associated with a specified key. If an object already exists for the given key, the object will be overwritten with the new value. + /// + /// - parameter value: The Double value to save. + /// - parameter forKey: The Double to save the Double under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: Double, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + return set(NSNumber(value: value), forKey: key, withAccessibility: accessibility) + } + + /// Save a Bool value to the keychain associated with a specified key. If an object already exists for the given key, the object will be overwritten with the new value. + /// + /// - parameter value: The Bool value to save. + /// - parameter forKey: The key to save the Bool under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: Bool, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + return set(NSNumber(value: value), forKey: key, withAccessibility: accessibility) + } + + /// Save a String value to the keychain associated with a specified key. If a String value already exists for the given key, the string will be overwritten with the new value. + /// + /// - parameter value: The String value to save. + /// - parameter forKey: The key to save the String under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: String, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + if let data = value.data(using: .utf8) { + return set(data, forKey: key, withAccessibility: accessibility) + } else { + return false + } + } + + /// Save a NSCoding compliant object to the keychain associated with a specified key. If an object already exists for the given key, the object will be overwritten with the new value. + /// + /// - parameter value: The NSCoding compliant object to save. + /// - parameter forKey: The key to save the object under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: NSCoding, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + let data = try! NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: false) + + return set(data, forKey: key, withAccessibility: accessibility) + } + + /// Save a Data object to the keychain associated with a specified key. If data already exists for the given key, the data will be overwritten with the new value. + /// + /// - parameter value: The Data object to save. + /// - parameter forKey: The key to save the object under. + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. + /// - returns: True if the save was successful, false otherwise. + @discardableResult open func set( + _ value: Data, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + var keychainQueryDictionary: [String:Any] = setupKeychainQueryDictionary(forKey: key, withAccessibility: accessibility) + + keychainQueryDictionary[SecValueData] = value + + if let accessibility = accessibility { + keychainQueryDictionary[SecAttrAccessible] = accessibility.keychainAttrValue + } else { + // Assign default protection - Protect the keychain entry so it's only valid when the device is unlocked + keychainQueryDictionary[SecAttrAccessible] = KeychainItemAccessibility.whenUnlocked.keychainAttrValue + } + + let status: OSStatus = SecItemAdd(keychainQueryDictionary as CFDictionary, nil) + + if status == errSecSuccess { + return true + } else if status == errSecDuplicateItem { + return update(value, forKey: key, withAccessibility: accessibility) + } else { + return false + } + } + + @available(*, deprecated, message: "remove is deprecated, use removeObject instead") + @discardableResult open func remove( + key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + return removeObject(forKey: key, withAccessibility: accessibility) + } + + /// Remove an object associated with a specified key. If re-using a key but with a different accessibility, first remove the previous key value using removeObjectForKey(:withAccessibility) using the same accessibilty it was saved with. + /// + /// - parameter forKey: The key value to remove data for. + /// - parameter withAccessibility: Optional accessibility level to use when looking up the keychain item. + /// - returns: True if successful, false otherwise. + @discardableResult open func removeObject( + forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + let keychainQueryDictionary: [String:Any] = setupKeychainQueryDictionary(forKey: key, withAccessibility: accessibility) + + // Delete + let status: OSStatus = SecItemDelete(keychainQueryDictionary as CFDictionary) + + if status == errSecSuccess { + return true + } else { + return false + } + } + + /// Remove all keychain data added through KeychainWrapper. This will only delete items matching the currnt ServiceName and AccessGroup if one is set. + /// - Returns: True if successful, false otherwise. + open func removeAllKeys() -> Bool { + // Setup dictionary to access keychain and specify we are using a generic password (rather than a certificate, internet password, etc) + var keychainQueryDictionary: [String:Any] = [SecClass:kSecClassGenericPassword] + + // Uniquely identify this keychain accessor + keychainQueryDictionary[SecAttrService] = serviceName + + // Set the keychain access group if defined + if let accessGroup = self.accessGroup { + keychainQueryDictionary[SecAttrAccessGroup] = accessGroup + } + + let status: OSStatus = SecItemDelete(keychainQueryDictionary as CFDictionary) + + if status == errSecSuccess { + return true + } else { + return false + } + } + + /// Remove all keychain data, including data not added through keychain wrapper. + /// + /// - Warning: This may remove custom keychain entries you did not add via SwiftKeychainWrapper. + /// + open class func wipeKeychain() { + deleteKeychainSecClass(kSecClassGenericPassword) // Generic password items + deleteKeychainSecClass(kSecClassInternetPassword) // Internet password items + deleteKeychainSecClass(kSecClassCertificate) // Certificate items + deleteKeychainSecClass(kSecClassKey) // Cryptographic key items + deleteKeychainSecClass(kSecClassIdentity) // Identity items + } + + // MARK:- Private Methods + + /// Remove all items for a given Keychain Item Class + /// - Parameter secClass: AnyObject which is the secClass of the items to be deleted + /// - Returns: True if successful, false otherwise. + @discardableResult private class func deleteKeychainSecClass(_ secClass: AnyObject) -> Bool { + let query = [SecClass: secClass] + let status: OSStatus = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess { + return true + } else { + return false + } + } + + /// Update existing data associated with a specified key name. The existing data will be overwritten by the new data. + /// - Parameters: + /// - value: The Data object to update + /// - key: The key to update the object under + /// - accessibility: Optional accessibility to use when setting the keychain item. + /// - Returns: True if successful, false otherwise. + private func update( + _ value: Data, forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> Bool { + var keychainQueryDictionary: [String:Any] = setupKeychainQueryDictionary(forKey: key, withAccessibility: accessibility) + let updateDictionary = [SecValueData:value] + + // on update, only set accessibility if passed in + if let accessibility = accessibility { + keychainQueryDictionary[SecAttrAccessible] = accessibility.keychainAttrValue + } + + // Update + let status: OSStatus = SecItemUpdate(keychainQueryDictionary as CFDictionary, updateDictionary as CFDictionary) + + if status == errSecSuccess { + return true + } else { + return false + } + } + + /// Setup the keychain query dictionary used to access the keychain on iOS for a specified key name. Takes into account the Service Name and Access Group if one is set. + /// - parameter forKey: The key this query is for + /// - parameter withAccessibility: Optional accessibility to use when setting the keychain item. If none is provided, will default to .WhenUnlocked + /// - returns: A dictionary with all the needed properties setup to access the keychain on iOS + private func setupKeychainQueryDictionary( + forKey key: String, withAccessibility accessibility: KeychainItemAccessibility? = nil + ) -> [String:Any] { + // Setup default access as generic password (rather than a certificate, internet password, etc) + var keychainQueryDictionary: [String:Any] = [SecClass:kSecClassGenericPassword] + + // Uniquely identify this keychain accessor + keychainQueryDictionary[SecAttrService] = serviceName + + // Only set accessibiilty if its passed in, we don't want to default it here in case the user didn't want it set + if let accessibility = accessibility { + keychainQueryDictionary[SecAttrAccessible] = accessibility.keychainAttrValue + } + + // Set the keychain access group if defined + if let accessGroup = self.accessGroup { + keychainQueryDictionary[SecAttrAccessGroup] = accessGroup + } + +// Uniquely identify the account who will be accessing the keychain + let encodedIdentifier: Data? = key.data(using: String.Encoding.utf8) + + keychainQueryDictionary[SecAttrGeneric] = encodedIdentifier + + keychainQueryDictionary[SecAttrAccount] = key + + return keychainQueryDictionary + } +} diff --git a/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/MajorMigrationTestsV1.swift b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/MajorMigrationTestsV1.swift new file mode 100644 index 0000000..80c0459 --- /dev/null +++ b/GoodPersistence-Sample/GoodPersistenceKeychainTests/MajorMigrationTestsV1/MajorMigrationTestsV1.swift @@ -0,0 +1,45 @@ +// +// MajorMigrationTestsV1.swift +// +// +// Created by Andrej Jasso on 10/04/2024. +// + +import XCTest +import GoodPersistence + +typealias VoidClosure = (() -> ()) + +final class KeychainMajorMigrationV1Tests: XCTestCase { + + enum C { + + static let keychainObjectKey = "TestString" + static let testValue = "Hello there general Kenobi" + + } + + @KeychainValueV1(C.keychainObjectKey, defaultValue: C.testValue) + var testString: String? + + @KeychainValue(C.keychainObjectKey, defaultValue: C.testValue) + var testString2: String? + + func testMigrationFromv1tov2() { + testString = nil + + XCTAssert(self.testString == nil) + XCTAssert(self.testString2 == nil) + + self.testString2 = C.testValue + + XCTAssert(self.testString == C.testValue) + XCTAssert(self.testString2 == C.testValue) + } + + override class func tearDown() { + try? Keychain.default.remove(C.keychainObjectKey) + } + +} + diff --git a/Sources/GoodPersistence/Configuration.swift b/Sources/GoodPersistence/GoodPersistence.swift similarity index 51% rename from Sources/GoodPersistence/Configuration.swift rename to Sources/GoodPersistence/GoodPersistence.swift index 101536a..e1fe918 100644 --- a/Sources/GoodPersistence/Configuration.swift +++ b/Sources/GoodPersistence/GoodPersistence.swift @@ -1,13 +1,12 @@ // -// Configuration.swift -// +// GoodPersistence.swift // // Created by Dominik Pethö on 05/04/2024. // public final class GoodPersistence { - /// Used for configuring the GoodPoersistance monitors + /// Used for configuring the GoodPersistence monitors public final class Configuration { public static private(set) var monitors: [PersistenceMonitor] = [] @@ -19,4 +18,16 @@ public final class GoodPersistence { } + static func log(error: Error) { + GoodPersistence.Configuration.monitors.forEach { + $0.didReceive($0, error: error) + } + } + + static func log(message: String) { + GoodPersistence.Configuration.monitors.forEach { + $0.didReceive($0, message: message) + } + } + } diff --git a/Sources/GoodPersistence/KeychainValue.swift b/Sources/GoodPersistence/KeychainValue.swift index 5a25fc0..098f88f 100644 --- a/Sources/GoodPersistence/KeychainValue.swift +++ b/Sources/GoodPersistence/KeychainValue.swift @@ -1,3 +1,4 @@ +// // KeychainValue.swift // // Created by Sebastián Mráz on 3/1/24. @@ -27,7 +28,21 @@ public struct KeychainConfiguration { let protocolType: ProtocolType? let accessGroup: String? let authenticationType: AuthenticationType? - + + init( + service: String? = nil, + server: String? = nil, + protocolType: ProtocolType? = nil, + accessGroup: String? = nil, + authenticationType: AuthenticationType? = nil + ) { + self.service = service + self.server = server + self.protocolType = protocolType + self.accessGroup = accessGroup + self.authenticationType = authenticationType + } + } /// A utility enum for managing Keychain operations and configuration. @@ -74,8 +89,26 @@ public enum Keychain { /// - `accessError`: An error indicating an issue with accessing or retrieving data from the Keychain. /// - `decodeError`: An error indicating a problem decoding data retrieved from the Keychain. /// - `encodeError`: An error indicating a problem encoding data before storing it in the Keychain. -public enum KeychainError: Error { - +public enum KeychainError: Error, Equatable, Hashable { + + public static func == (lhs: KeychainError, rhs: KeychainError) -> Bool { + lhs.hashValue == rhs.hashValue + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .accessError(let error): + hasher.combine(self.localizedDescription) + hasher.combine(error.localizedDescription) + case .decodeError(let error): + hasher.combine(self.localizedDescription) + hasher.combine(error.localizedDescription) + case .encodeError(let error): + hasher.combine(self.localizedDescription) + hasher.combine(error.localizedDescription) + } + } + case accessError(Error) case decodeError(Error) case encodeError(Error) @@ -89,7 +122,7 @@ public enum KeychainError: Error { @available(iOS 13.0, *) @propertyWrapper public class KeychainValue { - + // MARK: - Initialization /// Initializes a `KeychainValue` instance with a given key, default value, accessibility, and optional synchronization and authentication policy. @@ -112,6 +145,8 @@ public class KeychainValue { self.accessibility = accessibility self.synchronizable = synchronizable self.authenticationPolicy = authenticationPolicy + + Keychain.configure(with: .init(service: Bundle.main.bundleIdentifier ?? "SwiftKeychainWrapper")) } // MARK: - Wrapper @@ -131,15 +166,94 @@ public class KeychainValue { // MARK: - Properties - private let subject: PassthroughSubject = PassthroughSubject() - private let newSubject: PassthroughSubject = PassthroughSubject() - + private let valueSubject: PassthroughSubject = PassthroughSubject() + private let key: String private let defaultValue: T private let accessibility: KeychainAccess.Accessibility? private let synchronizable: Bool private let authenticationPolicy: KeychainAccess.AuthenticationPolicy? - + + public func retrieveValue(key: String) throws -> T { + // Setting up the Keychain for retrieval. + let keychain = setupKeychain() + do { + // Attempting to retrieve data from the Keychain using the specified key. + guard let data = try keychain.getData(key) + else { + // If no data is found in the Keychain, return the default value. + GoodPersistence.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Empty data.") + return defaultValue + } + do { + return try decodeJSON(data: data) + } catch { + do { + // ONLY SERVES AS BACKWARDS COMPATIBILITY ADAPTER FROM V1->V2 + return try decodePlist(data: data) + } catch { + throw error + } + } + } catch { + GoodPersistence.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Keychain access.") + throw error + } + } + + func decodeJSON(data: Data) throws -> T { + do { + // Decoding the retrieved data to get the value using Json Decoder. + return try JSONDecoder().decode(Wrapper.self, from: data).value + } catch { + GoodPersistence.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error using JSON Decoder.") + throw error + } + } + + func decodePlist(data: Data) throws -> T { + do { + // Decoding fallback of retrieved data to get the value using Plist Decoder. + return try PropertyListDecoder().decode(Wrapper.self, from: data).value + } catch { + GoodPersistence.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error using PList Decoder.") + throw error + } + } + + private func saveValue(key: String, newValue: T) throws { + // Setting up the Keychain for storage. + let keychain = setupKeychain() + if newValue == defaultValue { + // If the new value is equal to the default value, remove the corresponding entry from the Keychain. + do { + try keychain.remove(key) + } catch { + GoodPersistence.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Removing from keychain failed.") + throw error + } + } else { + // If the new value is different from the default value, wrap it in a Wrapper structure for encoding. + let wrapper = Wrapper(value: newValue) + + do { + // Encoding the wrapped value. + let data = try JSONEncoder().encode(wrapper) + do { + // Storing data in the Keychain using the specified key + try keychain.set(data, key: key) + } catch { + GoodPersistence.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Data encoding failed.") + throw error + } + } catch { + GoodPersistence.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Data encoding failed.") + throw error + } + GoodPersistence.log(message: "Keychain Data for key [\(key)] has changed to \(newValue).") + } + } + /// Provides the wrapped value retrieved from the Keychain or the default value. /// /// Use this property to access the value stored in the Keychain. If the value does not exist in the Keychain, @@ -149,81 +263,21 @@ public class KeychainValue { /// made to this property will be reflected in the Keychain as well. public var wrappedValue: T { get { - // Setting up the Keychain for retrieval. - let keychain = setupKeychain() do { - // Attempting to retrieve data from the Keychain using the specified key. - guard let data = try keychain.getData(key) - else { - // If no data is found in the Keychain, return the default value. - PersistenceLogger.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Empty data.") - return defaultValue - } - do { - // Decoding the retrieved data to get the value. - // If decoding fails, a failure completion event is sent to the subject, and the default value is returned. - let value = try JSONDecoder().decode(Wrapper.self, from: data).value - - return value - } catch { - // Sending a failure completion event to the subject if decoding fails, and returning the default value. - newSubject.send(completion: .failure(.decodeError(error))) - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error.") - return defaultValue - } + return try retrieveValue(key: key) } catch { - // Sending a failure completion event to the subject if there's an issue accessing the Keychain, and returning the default value. - newSubject.send(completion: .failure(.accessError(error))) - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Default keychain value [\(defaultValue)] for key [\(key)] used. Reason: Keychain access.") + GoodPersistence.log(error: error) + return defaultValue } } set(newValue) { - // Setting up the Keychain for storage. - let keychain = setupKeychain() - if newValue == defaultValue { - // If the new value is equal to the default value, remove the corresponding entry from the Keychain. - do { - try keychain.remove(key) - } catch { - // Sending a failure completion event to the subject if there's an issue removing the entry from the Keychain. - newSubject.send(completion: .failure(.accessError(error))) - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Removing from keychain failed.") - return - } - } else { - // If the new value is different from the default value, wrap it in a Wrapper structure for encoding. - let wrapper = Wrapper(value: newValue) - - do { - // Encoding the wrapped value. - let data = try JSONEncoder().encode(wrapper) - do { - // Storing data in the Keychain using the specified key - try keychain.set(data, key: key) - } catch { - // Sending a failure completion event to the subject if there's an issue storing the data in the Keychain. - newSubject.send(completion: .failure(.accessError(error))) - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Data encoding failed.") - return - } - } catch { - // Sending a failure completion event to the subject if there's an issue encoding the value. - newSubject.send(completion: .failure(.encodeError(error))) - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Setting keychain value [\(defaultValue)] for key [\(key)] not performed. Reason: Data encoding failed.") - return - } + do { + try saveValue(key: key, newValue: newValue) + } catch { + GoodPersistence.log(error: error) } - // Sending the new value through the subject after successful Keychain operations. - newSubject.send(newValue) - subject.send(newValue) - PersistenceLogger.log(message: "Keychain Data for key [\(key)] has changed to \(newValue).") } } @@ -239,29 +293,6 @@ public class KeychainValue { }) } - /// **Deprecated:** Use `valuePublisher` property instead. - /// - /// The `publisher` property provides an `AnyPublisher` that sends the current value of `wrappedValue`, - /// followed by any future changes. It is deprecated in favor of the more descriptive `valuePublisher` - /// property, which includes error handling for Keychain-related errors. - /// - /// - Note: This property will be removed in future releases. Please update your code to use `valuePublisher`. - @available(*, deprecated, message: "Please use valuePublisher: AnyPublisher instead") - public lazy var publisher: AnyPublisher = { - if let authenticationPolicy { - Deferred { - self.subject - .share(replay: 1) - }.eraseToAnyPublisher() - } else { - Deferred { - self.subject - .prepend(self.wrappedValue) - .share(replay: 1) - }.eraseToAnyPublisher() - } - }() - /// The `valuePublisher` property provides an `AnyPublisher` that sends the current value of `wrappedValue`, /// followed by any future changes, with error handling for Keychain-related errors. /// @@ -269,21 +300,22 @@ public class KeychainValue { /// through a publisher that includes error information when applicable. /// /// - Note: The publisher shares the current value on subscription, followed by subsequent changes. - public lazy var valuePublisher: AnyPublisher = { + /// + public lazy var valuePublisher: AnyPublisher = { if let authenticationPolicy { Deferred { - self.newSubject + self.valueSubject .share(replay: 1) }.eraseToAnyPublisher() } else { Deferred { - self.newSubject + self.valueSubject .prepend(self.wrappedValue) .share(replay: 1) }.eraseToAnyPublisher() } }() - + /// Sets up and returns a `KeychainAccess.Keychain` instance based on the provided configurations. /// /// This method constructs a `KeychainAccess.Keychain` instance with optional accessibility and authentication policy, diff --git a/Sources/GoodPersistence/LoggingMonitor/LoggingPersistenceMonitor.swift b/Sources/GoodPersistence/LoggingMonitor/LoggingPersistenceMonitor.swift new file mode 100644 index 0000000..fb3e5da --- /dev/null +++ b/Sources/GoodPersistence/LoggingMonitor/LoggingPersistenceMonitor.swift @@ -0,0 +1,25 @@ +// +// LoggingPersistenceMonitor.swift +// +// Created by Andrej Jasso on 12/04/2024. +// + +import Foundation + +public final class LoggingPersistenceMonitor: PersistenceMonitor { + + private var logger: (any PersistanceLogger)? + + public init(logger: (any PersistanceLogger)?) { + self.logger = logger + } + + public func didReceive(_ monitor: any PersistenceMonitor, error: any Error) { + logger?.log(level: .error, message: error.localizedDescription) + } + + public func didReceive(_ monitor: any PersistenceMonitor, message: String) { + logger?.log(level: .info, message: message) + } + +} diff --git a/Sources/GoodPersistence/LoggingMonitor/OSLogger.swift b/Sources/GoodPersistence/LoggingMonitor/OSLogger.swift new file mode 100644 index 0000000..dc35058 --- /dev/null +++ b/Sources/GoodPersistence/LoggingMonitor/OSLogger.swift @@ -0,0 +1,21 @@ +// +// OSLogLogger.swift +// +// Created by Matus Klasovity on 30/01/2024. +// + +import Foundation +import OSLog + +@available(iOS 14, *) +public final class OSLogLogger: PersistanceLogger { + + private let logger = Logger(subsystem: "OSLogSessionLogger", category: "Networking") + + public init() {} + + public func log(level: OSLogType, message: String) { + logger.log(level: level, "\(message)") + } + +} diff --git a/Sources/GoodPersistence/LoggingMonitor/PersistenceLogger.swift b/Sources/GoodPersistence/LoggingMonitor/PersistenceLogger.swift new file mode 100644 index 0000000..108f8f9 --- /dev/null +++ b/Sources/GoodPersistence/LoggingMonitor/PersistenceLogger.swift @@ -0,0 +1,14 @@ +// +// PersistenceLogger.swift +// +// Created by Andrej Jasso on 12/04/2024. +// + +import Foundation +import OSLog + +public protocol PersistanceLogger { + + func log(level: OSLogType, message: String) + +} diff --git a/Sources/GoodPersistence/LoggingMonitor/PrintLogger.swift b/Sources/GoodPersistence/LoggingMonitor/PrintLogger.swift new file mode 100644 index 0000000..902fed1 --- /dev/null +++ b/Sources/GoodPersistence/LoggingMonitor/PrintLogger.swift @@ -0,0 +1,18 @@ +// +// PrintLogger.swift +// +// Created by Matus Klasovity on 30/01/2024. +// + +import Foundation +import OSLog + +public final class PrintLogger: PersistanceLogger { + + public init() {} + + public func log(level: OSLogType, message: String) { + print(message) + } + +} diff --git a/Sources/GoodPersistence/PersistenceLogger.swift b/Sources/GoodPersistence/PersistenceLogger.swift deleted file mode 100644 index 5f88ad0..0000000 --- a/Sources/GoodPersistence/PersistenceLogger.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// PersistenceLogger.swift -// -// -// Created by Dominik Pethö on 05/04/2024. -// - -final class PersistenceLogger { - - static func log(error: Error) { - GoodPersistence.Configuration.monitors.forEach { - $0.didReceive($0, error: error) - } - } - - static func log(message: String) { - GoodPersistence.Configuration.monitors.forEach { - $0.didReceive($0, message: message) - } - } - -} - diff --git a/Sources/GoodPersistence/PersistenceMonitor.swift b/Sources/GoodPersistence/PersistenceMonitor.swift index 7353b93..965b664 100644 --- a/Sources/GoodPersistence/PersistenceMonitor.swift +++ b/Sources/GoodPersistence/PersistenceMonitor.swift @@ -1,7 +1,6 @@ // // PersistenceMonitor.swift // -// // Created by Dominik Pethö on 05/04/2024. // diff --git a/Sources/GoodPersistence/UserDefaultsWrapper.swift b/Sources/GoodPersistence/UserDefaultsWrapper.swift index d6bf5ad..87aa394 100644 --- a/Sources/GoodPersistence/UserDefaultsWrapper.swift +++ b/Sources/GoodPersistence/UserDefaultsWrapper.swift @@ -1,10 +1,10 @@ +// // UserDefaultsWrapper.swift // // Created by Dominik Pethö on 1/15/21. // // https://github.com/jrendel/SwiftKeychainWrapper - import Foundation import Combine import CombineExt @@ -35,42 +35,75 @@ public class UserDefaultValue { self.key = key self.defaultValue = defaultValue } - + + // Retrieves Value or Throws Error + private func retrieveValue(key: String) throws -> T { + // If the data is of the correct type, return it. + if let data = UserDefaults.standard.value(forKey: key) as? T { + return data + } + // If the data isn't of the correct type, try to decode it from the Data stored in UserDefaults. + guard let data = UserDefaults.standard.object(forKey: key) as? Data else { + GoodPersistence.log(message: "GoodPersistence: UserDefaults value [\(defaultValue)] for key [\(key)] used. Reason: Data not retrieved.") + return defaultValue + } + + do { + return try decodeJSON(data: data) + } catch { + do { + // ONLY SERVES AS BACKWARDS COMPATIBILITY ADAPTER FROM V1->V2 + return try decodePlist(data: data) + } catch { + throw error + } + } + } + + func decodeJSON(data: Data) throws -> T { + do { + // Decoding the retrieved data to get the value using Json Decoder. + return try JSONDecoder().decode(Wrapper.self, from: data).value + } catch { + GoodPersistence.log(message: "GoodPersistence: UserDefaults value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error using JSON Decoder.") + throw error + } + } + + func decodePlist(data: Data) throws -> T { + do { + // Decoding fallback of retrieved data to get the value using Plist Decoder. + return try PropertyListDecoder().decode(Wrapper.self, from: data).value + } catch { + GoodPersistence.log(message: "GoodPersistence: UserDefaults value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error using PList Decoder.") + throw error + } + } + // This property is marked as a property wrapper, which means that it provides additional functionality around a stored value. public var wrappedValue: T { get { - // If the data is of the correct type, return it. - if let data = UserDefaults.standard.value(forKey: key) as? T { - return data - } - - // If the data isn't of the correct type, try to decode it from the Data stored in UserDefaults. - guard let data = UserDefaults.standard.object(forKey: key) as? Data else { - PersistenceLogger.log(message: "Default UserDefaults value [\(defaultValue)] for key [\(key)] used. Reason: Data not retrieved.") - return defaultValue - } - do { - let value = try PropertyListDecoder().decode(Wrapper.self, from: data).value - return value + return try retrieveValue(key: key) } catch { - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Default UserDefaults value [\(defaultValue)] for key [\(key)] used. Reason: Decoding error.") + // Sending a failure completion event to the subject if decoding fails, and returning the default value. + GoodPersistence.log(error: error) return defaultValue } } + set(newValue) { // Wrap the new value in a Wrapper, and store the encoded Data in UserDefaults. let wrapper = Wrapper(value: newValue) do { - let value = try PropertyListEncoder().encode(wrapper) + let value = try JSONEncoder().encode(wrapper) UserDefaults.standard.set(value, forKey: key) subject.send(newValue) - PersistenceLogger.log(message: "UserDefaults data for key [\(key)] has changed to \(newValue).") + GoodPersistence.log(message: "GoodPersistence: data for key [\(key)] has changed to \(newValue).") } catch { - PersistenceLogger.log(error: error) - PersistenceLogger.log(message: "Setting UserDefaults value [\(defaultValue)] for key [\(key)] not performed. Reason: Encoding error.") + GoodPersistence.log(error: error) + GoodPersistence.log(message: "GoodPersistence: saving UserDefaults value [\(defaultValue)] for key [\(key)] not performed. Reason: Encoding error.") } } } diff --git a/Tests/GoodPersistenceTests/MigrationTests/MajorMigrationTestsV1.swift b/Tests/GoodPersistenceTests/MigrationTests/MajorMigrationTestsV1.swift new file mode 100644 index 0000000..03f617e --- /dev/null +++ b/Tests/GoodPersistenceTests/MigrationTests/MajorMigrationTestsV1.swift @@ -0,0 +1,44 @@ +// +// MajorMigrationTestsV1.swift +// +// +// Created by Andrej Jasso on 12/04/2024. +// + +import XCTest +import GoodPersistence + +typealias VoidClosure = (() -> ()) + +final class KeychainMajorMigrationV1Tests: XCTestCase { + + enum C { + + static let keychainObjectKey = "TestString" + static let testValue = "Hello there general Kenobi" + + } + + @UserDefaultValueV1(C.keychainObjectKey, defaultValue: C.testValue) + var testString: String? + + @UserDefaultValue(C.keychainObjectKey, defaultValue: C.testValue) + var testString2: String? + + func testMigrationFromv1tov2() { + testString = nil + + XCTAssert(self.testString == nil) + XCTAssert(self.testString2 == nil) + + self.testString2 = C.testValue + + XCTAssert(self.testString == C.testValue) + XCTAssert(self.testString2 == C.testValue) + } + + override class func tearDown() { + } + +} + diff --git a/Tests/GoodPersistenceTests/MigrationTests/UserDefaultsWrapperV1.swift b/Tests/GoodPersistenceTests/MigrationTests/UserDefaultsWrapperV1.swift new file mode 100644 index 0000000..0f8923c --- /dev/null +++ b/Tests/GoodPersistenceTests/MigrationTests/UserDefaultsWrapperV1.swift @@ -0,0 +1,90 @@ +// UserDefaultsWrapper.swift +// +// Created by Dominik Pethö on 1/15/21. +// +// https://github.com/jrendel/SwiftKeychainWrapper + +import Combine +import CombineExt +import SwiftUI +import GoodPersistence + +/// The UserDefaultValue wraps a value of any type that conforms to the Codable protocol, in order to store it in UserDefaults +@available(iOS 13.0, *) +@propertyWrapper +public class UserDefaultValueV1 { + + /// A struct that wraps the value that we want to store in UserDefaults, in order to store values of types conforming to Codable. + struct Wrapper: Codable { + + let value: T + + } + + // A PassthroughSubject is a subject that can pass values directly to its subscribers. + private let subject: PassthroughSubject = PassthroughSubject() + private let key: String + private let defaultValue: T + + /// Initializes a UserDefaultValue instance with a given key and default value. + /// - Parameters: + /// - key: The key for the UserDefaultValue item + /// - defaultValue: The default value for the UserDefaultValue item + public init(_ key: String, defaultValue: T) { + self.key = key + self.defaultValue = defaultValue + } + + // This property is marked as a property wrapper, which means that it provides additional functionality around a stored value. + public var wrappedValue: T { + get { + // If the data is of the correct type, return it. + if let data = UserDefaults.standard.value(forKey: key) as? T { + return data + } + + // If the data isn't of the correct type, try to decode it from the Data stored in UserDefaults. + guard let data = UserDefaults.standard.object(forKey: key) as? Data else { + return defaultValue + } + + do { + let value = try PropertyListDecoder().decode(Wrapper.self, from: data).value + return value + } catch { + return defaultValue + } + } + set(newValue) { + // Wrap the new value in a Wrapper, and store the encoded Data in UserDefaults. + let wrapper = Wrapper(value: newValue) + + do { + let value = try PropertyListEncoder().encode(wrapper) + UserDefaults.standard.set(value, forKey: key) + subject.send(newValue) + } catch { + + } + } + } + + // This property is marked as a property wrapper, which means that it provides additional functionality around a stored value. + public var projectedValue: Binding { + Binding(get: { + return self.wrappedValue + }, set: { newValue in + self.wrappedValue = newValue + }) + } + + /// The publisher property provides an AnyPublisher that sends the current value of wrappedValue, followed by any future changes. + public lazy var publisher: AnyPublisher = { + Deferred { + self.subject + .prepend(self.wrappedValue) + .share(replay: 1) + }.eraseToAnyPublisher() + }() + +} diff --git a/Tests/GoodPersistenceTests/TestMonitor.swift b/Tests/GoodPersistenceTests/TestMonitor.swift index f468cb2..b43fc3a 100644 --- a/Tests/GoodPersistenceTests/TestMonitor.swift +++ b/Tests/GoodPersistenceTests/TestMonitor.swift @@ -1,24 +1,24 @@ // -// File.swift -// +// TestMonitor.swift // // Created by Dominik Pethö on 05/04/2024. // + import GoodPersistence final class TestMonitor: PersistenceMonitor { - var error: Error? - var message: String? + var errors: [Error] = [] + var messages: [String] = [] func didReceive(_ monitor: PersistenceMonitor, error: Error) { debugPrint(error) - self.error = error + self.errors.append(error) } func didReceive(_ monitor: PersistenceMonitor, message: String) { debugPrint(message) - self.message = message + self.messages.append(message) } } diff --git a/Tests/GoodPersistenceTests/UserDefaultTest.swift b/Tests/GoodPersistenceTests/UserDefaultTest.swift index 7b1f491..6c3c3ac 100644 --- a/Tests/GoodPersistenceTests/UserDefaultTest.swift +++ b/Tests/GoodPersistenceTests/UserDefaultTest.swift @@ -39,10 +39,11 @@ final class UserDefaultsTests: XCTestCase { var test: EmptyTest let _ = test + let firstMessage = monitor.messages.first XCTAssert( - monitor.message == "Default UserDefaults value [EmptyTest(value: \"\")] for key [Test Monitor] used. Reason: Data not retrieved.", - "Monitor should contain message for using default value. Contains: \(monitor.message)." + firstMessage == "GoodPersistence: UserDefaults value [EmptyTest(value: \"\")] for key [Test Monitor] used. Reason: Data not retrieved.", + "Monitor should contain message for using default value. Contains: \(String(describing: firstMessage))." ) } @@ -58,10 +59,11 @@ final class UserDefaultsTests: XCTestCase { var test: EmptyTest test = .init(value: "newValue") + let firstMessage = monitor.messages.first XCTAssert( - monitor.message == "UserDefaults data for key [Test Monitor] has changed to EmptyTest(value: \"newValue\").", - "Monitor should contain message for using default value. Contains: \(monitor.message)." + firstMessage == "GoodPersistence: data for key [Test Monitor] has changed to EmptyTest(value: \"newValue\").", + "Monitor should contain message for using default value. Contains: \(String(describing: firstMessage))." ) } @@ -85,15 +87,24 @@ final class UserDefaultsTests: XCTestCase { @UserDefaultValue(C.userDefaultsObjectTestMonitorKey, defaultValue: .init(value: "", secondValue: "")) var testFailure: EmptyTestFailure let _ = testFailure + let firstErrors = monitor.errors.first + + let testMessage1 = "GoodPersistence: UserDefaults value [EmptyTestFailure(value: \"\", secondValue: \"\")] for key [Test Monitor] used. Reason: Decoding error using JSON Decoder." + let testMessage2 = "GoodPersistence: UserDefaults value [EmptyTestFailure(value: \"\", secondValue: \"\")] for key [Test Monitor] used. Reason: Decoding error using PList Decoder." + + XCTAssert( + monitor.messages.contains { $0 == testMessage1}, + "Monitor should contain message for using default value. Contains: \(monitor.messages))." + ) XCTAssert( - monitor.message == "Default UserDefaults value [EmptyTestFailure(value: \"\", secondValue: \"\")] for key [Test Monitor] used. Reason: Decoding error.", - "Monitor should contain message for using default value. Contains: \(monitor.message)." + monitor.messages.contains { $0 == testMessage2}, + "Monitor should contain message for using default value. Contains: \(monitor.messages))." ) XCTAssert( - monitor.error != nil, - "Monitor should contain error for using default value. Contains error: \(monitor.error)." + firstErrors != nil, + "Monitor should contain error for using default value. Contains error: \(String(describing: firstErrors))." ) } @@ -117,10 +128,11 @@ final class UserDefaultsTests: XCTestCase { @UserDefaultValue(C.userDefaultsObjectTestMonitorKey, defaultValue: .init(value: "", secondValue: "")) var testFailure: EmptyTestFailure let _ = testFailure + let firstErrors = monitor.errors.first XCTAssert( - monitor.error != nil, - "Monitor should contain error for using default value. Contains error: \(monitor.error)." + firstErrors != nil, + "Monitor should contain error for using default value. Contains error: \(String(describing: firstErrors))." ) }