diff --git a/README.md b/README.md index 4834268c..f764ab9b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ getData = async () => { ``` -See docs for [api and more examples.](docs/API.md) +See docs for [api and more examples](docs/API.md), and [brownfield integration guide](docs/AdvancedUsage.md). ## Writing tests @@ -66,4 +66,4 @@ See the [CONTRIBUTING](CONTRIBUTING.md) file for how to help out. ## License -MIT \ No newline at end of file +MIT diff --git a/docs/AdvancedUsage.md b/docs/AdvancedUsage.md new file mode 100644 index 00000000..c85594c6 --- /dev/null +++ b/docs/AdvancedUsage.md @@ -0,0 +1,87 @@ +# Integrating Async Storage with embedded React Native apps + +If you're embedding React Native into native application, you can also integrate +Async Storage module, so that both worlds will use one storage solution. + +## iOS + +AsyncStorage can be controlled by the hosting app via the delegate on +`RNCAsyncStorage`: + +```objc +RNCAsyncStorage *asyncStorage = [bridge moduleForClass:[RNCAsyncStorage class]]; +asyncStorage.delegate = self; +``` + +### The procotol + +The delegate must conform to the `RNCAsyncStorageDelegate` protocol + +--- + +```objc +- (void)allKeys:(RNCAsyncStorageResultCallback)block; +``` + +Returns all keys currently stored. If none, an empty array is returned. +Called by `getAllKeys` in JS. + +--- + +```objc +- (void)mergeValues:(NSArray *)values + forKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; +``` + +Merges values with the corresponding values stored at specified keys. +Called by `mergeItem` and `multiMerge` in JS. + +--- + +```objc +- (void)removeAllValues:(RNCAsyncStorageCompletion)block; +``` + +Removes all values from the store. Called by `clear` in JS. + +--- + +```objc +- (void)removeValuesForKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; +``` + +Removes all values associated with specified keys. +Called by `removeItem` and `multiRemove` in JS. + +--- + +```objc +- (void)setValues:(NSArray *)values + forKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; +``` + +Sets specified key-value pairs. Called by `setItem` and `multiSet` in JS. + +--- + +```objc +- (void)valuesForKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; +``` + +Returns values associated with specified keys. +Called by `getItem` and `multiGet` in JS. + +--- + +```objc +@optional +@property (nonatomic, readonly, getter=isPassthrough) BOOL passthrough; +``` + +**Optional:** Returns whether the delegate should be treated as a passthrough. +This is useful for monitoring storage usage among other things. Returns `NO` by +default. diff --git a/example/e2e/asyncstorage.e2e.js b/example/e2e/asyncstorage.e2e.js index 5d1b4f50..1280c3b7 100644 --- a/example/e2e/asyncstorage.e2e.js +++ b/example/e2e/asyncstorage.e2e.js @@ -29,12 +29,19 @@ describe('Async Storage', () => { }); describe('get / set / clear item test', () => { - it('should be visible', async () => { + beforeAll(async () => { + if (device.getPlatform() === 'ios') { + await device.openURL({url: 'rnc-asyncstorage://unset-delegate'}); + } await test_getSetClear.tap(); + }); + + it('should be visible', async () => { await expect(element(by.id('clear_button'))).toExist(); await expect(element(by.id('increaseByTen_button'))).toExist(); await expect(element(by.id('storedNumber_text'))).toExist(); }); + it('should store value in async storage', async () => { const storedNumberText = await element(by.id('storedNumber_text')); const increaseByTenButton = await element(by.id('increaseByTen_button')); @@ -65,8 +72,14 @@ describe('Async Storage', () => { }); describe('merge item test', () => { - it('should be visible', async () => { + beforeAll(async () => { + if (device.getPlatform() === 'ios') { + await device.openURL({url: 'rnc-asyncstorage://unset-delegate'}); + } await test_mergeItem.tap(); + }); + + it('should be visible', async () => { await expect(element(by.id('saveItem_button'))).toExist(); await expect(element(by.id('mergeItem_button'))).toExist(); await expect(element(by.id('restoreItem_button'))).toExist(); @@ -139,4 +152,125 @@ describe('Async Storage', () => { expect(storyText).toHaveText(newStory); }); }); + + describe('get / set / clear item delegate test', () => { + beforeAll(async () => { + await test_getSetClear.tap(); + if (device.getPlatform() === 'android') { + // Not yet supported. + return; + } + + await device.openURL({url: 'rnc-asyncstorage://set-delegate'}); + }); + + it('should store value with delegate', async () => { + if (device.getPlatform() === 'android') { + // Not yet supported. + return; + } + + const storedNumberText = await element(by.id('storedNumber_text')); + const increaseByTenButton = await element(by.id('increaseByTen_button')); + + await expect(storedNumberText).toHaveText(''); + + const tapTimes = Math.round(Math.random() * 9) + 1; + + for (let i = 0; i < tapTimes; i++) { + await increaseByTenButton.tap(); + } + + await expect(storedNumberText).toHaveText(`${tapTimes * 10}`); + await restartButton.tap(); + + // The delegate will distinguish itself by always returning the stored value + 1000000 + await expect(storedNumberText).toHaveText(`${tapTimes * 10 + 1000000}`); + }); + + it('should clear item with delegate', async () => { + if (device.getPlatform() === 'android') { + // Not yet supported. + return; + } + + const storedNumberText = await element(by.id('storedNumber_text')); + const increaseByTenButton = await element(by.id('increaseByTen_button')); + const clearButton = await element(by.id('clear_button')); + + await increaseByTenButton.tap(); + await clearButton.tap(); + await restartButton.tap(); + + // The delegate will distinguish itself by actually setting storing 1000000 + // instead of clearing. It will also always return the stored value + 1000000. + await expect(storedNumberText).toHaveText('2000000'); + }); + }); + + describe('merge item delegate test', () => { + beforeAll(async () => { + if (device.getPlatform() === 'ios') { + await device.openURL({url: 'rnc-asyncstorage://set-delegate'}); + } + await test_mergeItem.tap(); + }); + + it('should merge items with delegate', async () => { + if (device.getPlatform() === 'android') { + // Not yet supported. + return; + } + + const buttonMergeItem = await element(by.id('mergeItem_button')); + const buttonRestoreItem = await element(by.id('restoreItem_button')); + + const nameInput = await element(by.id('testInput-name')); + const ageInput = await element(by.id('testInput-age')); + const eyesInput = await element(by.id('testInput-eyes')); + const shoeInput = await element(by.id('testInput-shoe')); + const storyText = await element(by.id('storyTextView')); + + const isAndroid = device.getPlatform() === 'android'; + + async function performInput() { + const name = Math.random() > 0.5 ? 'Jerry' : 'Sarah'; + const age = Math.random() > 0.5 ? '21' : '23'; + const eyesColor = Math.random() > 0.5 ? 'blue' : 'green'; + const shoeSize = Math.random() > 0.5 ? '9' : '10'; + + if (!isAndroid) { + await eyesInput.tap(); + } + await nameInput.typeText(name); + await closeKeyboard.tap(); + + if (!isAndroid) { + await eyesInput.tap(); + } + await ageInput.typeText(age); + await closeKeyboard.tap(); + + if (!isAndroid) { + await eyesInput.tap(); + } + await eyesInput.typeText(eyesColor); + await closeKeyboard.tap(); + + if (!isAndroid) { + await eyesInput.tap(); + } + await shoeInput.typeText(shoeSize); + await closeKeyboard.tap(); + + return `${name} from delegate is ${age} from delegate, has ${eyesColor} eyes and shoe size of ${shoeSize}.`; + } + + const story = await performInput(); + await buttonMergeItem.tap(); + await restartButton.tap(); + await buttonRestoreItem.tap(); + expect(storyText).toHaveText(story); + }); + }); }); diff --git a/example/ios/AsyncStorageExample.xcodeproj/project.pbxproj b/example/ios/AsyncStorageExample.xcodeproj/project.pbxproj index 37033d1a..16b0e1b5 100644 --- a/example/ios/AsyncStorageExample.xcodeproj/project.pbxproj +++ b/example/ios/AsyncStorageExample.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 146834051AC3E58100842450 /* libReact.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 146834041AC3E56700842450 /* libReact.a */; }; + 196F5D682254C1530035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 196F5D672254C1530035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.m */; }; + 19C469542256303E00CA1332 /* RNCTestAsyncStorageDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 19C469532256303E00CA1332 /* RNCTestAsyncStorageDelegate.m */; }; 3D82E3B72248BD39001F5D1A /* libRNCAsyncStorage.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DC5398C220F2C940035D3A3 /* libRNCAsyncStorage.a */; }; 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 832341B51AAA6A8300B99B32 /* libRCTText.a */; }; ADBDB9381DFEBF1600ED6528 /* libRCTBlob.a in Frameworks */ = {isa = PBXBuildFile; fileRef = ADBDB9271DFEBF0700ED6528 /* libRCTBlob.a */; }; @@ -84,6 +86,13 @@ remoteGlobalIDString = 83CBBA2E1A601D0E00E9B192; remoteInfo = React; }; + 1990B95122398FC4009E5EA1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3DC5395A220F2C940035D3A3 /* RNCAsyncStorage.xcodeproj */; + proxyType = 1; + remoteGlobalIDString = 58B511DA1A9E6C8500147676; + remoteInfo = RNCAsyncStorage; + }; 2D16E6711FA4F8DC00B85C8A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = ADBDB91F1DFEBF0600ED6528 /* RCTBlob.xcodeproj */; @@ -313,6 +322,10 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = AsyncStorageExample/Info.plist; sourceTree = ""; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = AsyncStorageExample/main.m; sourceTree = ""; }; 146833FF1AC3E56700842450 /* React.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = React.xcodeproj; path = "../../node_modules/react-native/React/React.xcodeproj"; sourceTree = ""; }; + 196F5D672254C1530035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "AppDelegate+RNCAsyncStorageDelegate.m"; path = "AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.m"; sourceTree = ""; }; + 196F5D8E2254C2C90035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "AppDelegate+RNCAsyncStorageDelegate.h"; path = "AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.h"; sourceTree = ""; }; + 19C4692D22562FD400CA1332 /* RNCTestAsyncStorageDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNCTestAsyncStorageDelegate.h; path = AsyncStorageExample/RNCTestAsyncStorageDelegate.h; sourceTree = ""; }; + 19C469532256303E00CA1332 /* RNCTestAsyncStorageDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = RNCTestAsyncStorageDelegate.m; path = AsyncStorageExample/RNCTestAsyncStorageDelegate.m; sourceTree = ""; }; 2D16E6891FA4F8E400B85C8A /* libReact.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libReact.a; sourceTree = BUILT_PRODUCTS_DIR; }; 3DC5395A220F2C940035D3A3 /* RNCAsyncStorage.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RNCAsyncStorage.xcodeproj; path = ../../ios/RNCAsyncStorage.xcodeproj; sourceTree = ""; }; 5E91572D1DD0AC6500FF2AA8 /* RCTAnimation.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTAnimation.xcodeproj; path = "../../node_modules/react-native/Libraries/NativeAnimation/RCTAnimation.xcodeproj"; sourceTree = ""; }; @@ -329,11 +342,9 @@ buildActionMask = 2147483647; files = ( 3D82E3B72248BD39001F5D1A /* libRNCAsyncStorage.a in Frameworks */, - ED297163215061F000B7C4FE /* JavaScriptCore.framework in Frameworks */, - ADBDB9381DFEBF1600ED6528 /* libRCTBlob.a in Frameworks */, - 11D1A2F320CAFA9E000508D9 /* libRCTAnimation.a in Frameworks */, - 146834051AC3E58100842450 /* libReact.a in Frameworks */, 00C302E51ABCBA2D00DB3ED1 /* libRCTActionSheet.a in Frameworks */, + 11D1A2F320CAFA9E000508D9 /* libRCTAnimation.a in Frameworks */, + ADBDB9381DFEBF1600ED6528 /* libRCTBlob.a in Frameworks */, 00C302E71ABCBA2D00DB3ED1 /* libRCTGeolocation.a in Frameworks */, 00C302E81ABCBA2D00DB3ED1 /* libRCTImage.a in Frameworks */, 133E29F31AD74F7200F7D852 /* libRCTLinking.a in Frameworks */, @@ -342,6 +353,8 @@ 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */, 00C302EA1ABCBA2D00DB3ED1 /* libRCTVibration.a in Frameworks */, 139FDEF61B0652A700C62182 /* libRCTWebSocket.a in Frameworks */, + 146834051AC3E58100842450 /* libReact.a in Frameworks */, + ED297163215061F000B7C4FE /* JavaScriptCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -416,6 +429,10 @@ 008F07F21AC5B25A0029DE68 /* main.jsbundle */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 196F5D8E2254C2C90035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.h */, + 196F5D672254C1530035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.m */, + 19C4692D22562FD400CA1332 /* RNCTestAsyncStorageDelegate.h */, + 19C469532256303E00CA1332 /* RNCTestAsyncStorageDelegate.m */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, @@ -557,6 +574,7 @@ buildRules = ( ); dependencies = ( + 1990B95222398FC4009E5EA1 /* PBXTargetDependency */, ); name = AsyncStorageExample; productName = "Hello World"; @@ -938,7 +956,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export NODE_BINARY=node\n../../node_modules/react-native/scripts/react-native-xcode.sh"; + shellScript = "export NODE_BINARY=node\n../../node_modules/react-native/scripts/react-native-xcode.sh\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -948,12 +966,22 @@ buildActionMask = 2147483647; files = ( 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 19C469542256303E00CA1332 /* RNCTestAsyncStorageDelegate.m in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, + 196F5D682254C1530035A6D3 /* AppDelegate+RNCAsyncStorageDelegate.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 1990B95222398FC4009E5EA1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = RNCAsyncStorage; + targetProxy = 1990B95122398FC4009E5EA1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { isa = PBXVariantGroup; diff --git a/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.h b/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.h new file mode 100644 index 00000000..5d50d691 --- /dev/null +++ b/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.h @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "AppDelegate.h" + +#import + +@interface AppDelegate (RNCAsyncStorageDelegate) +@end diff --git a/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.m b/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.m new file mode 100644 index 00000000..3e3ded31 --- /dev/null +++ b/example/ios/AsyncStorageExample/AppDelegate+RNCAsyncStorageDelegate.m @@ -0,0 +1,65 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "AppDelegate+RNCAsyncStorageDelegate.h" + +#import + +@implementation AppDelegate (RNCAsyncStorageDelegate) + +- (void)allKeys:(nonnull RNCAsyncStorageResultCallback)completion +{ + completion(self.memoryStorage.allKeys); +} + +- (void)mergeValues:(nonnull NSArray *)values + forKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)block +{ + [NSException raise:@"Unimplemented" + format:@"%@ is unimplemented", NSStringFromSelector(_cmd)]; +} + +- (void)removeAllValues:(nonnull RNCAsyncStorageCompletion)completion +{ + [self.memoryStorage removeAllObjects]; + completion(nil); +} + +- (void)removeValuesForKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + for (NSString *key in keys) { + [self.memoryStorage removeObjectForKey:key]; + } + completion(@[]); +} + +- (void)setValues:(nonnull NSArray *)values + forKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + for (NSUInteger i = 0; i < values.count; ++i) { + NSString *value = values[i]; + NSString *key = keys[i]; + [self.memoryStorage setObject:value forKey:key]; + } + completion(@[]); +} + +- (void)valuesForKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + NSMutableArray *values = [NSMutableArray arrayWithCapacity:keys.count]; + for (NSString *key in keys) { + NSString *value = self.memoryStorage[key]; + [values addObject:value == nil ? [NSNull null] : value]; + } + completion(values); +} + +@end diff --git a/example/ios/AsyncStorageExample/AppDelegate.h b/example/ios/AsyncStorageExample/AppDelegate.h index 4b5644f2..fa44dcbd 100644 --- a/example/ios/AsyncStorageExample/AppDelegate.h +++ b/example/ios/AsyncStorageExample/AppDelegate.h @@ -10,5 +10,6 @@ @interface AppDelegate : UIResponder @property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) NSMutableDictionary *memoryStorage; @end diff --git a/example/ios/AsyncStorageExample/AppDelegate.m b/example/ios/AsyncStorageExample/AppDelegate.m index 18f05547..0be1f588 100644 --- a/example/ios/AsyncStorageExample/AppDelegate.m +++ b/example/ios/AsyncStorageExample/AppDelegate.m @@ -7,13 +7,26 @@ #import "AppDelegate.h" +#import "AppDelegate+RNCAsyncStorageDelegate.h" +#import "RNCTestAsyncStorageDelegate.h" + +#import #import +#import #import -@implementation AppDelegate +@implementation AppDelegate { + __weak RCTBridge *_bridge; + RNCTestAsyncStorageDelegate *_testDelegate; +} - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(didLoadJavaScript:) + name:RCTJavaScriptDidLoadNotification + object:nil]; + NSURL *jsCodeLocation; jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"example/index" fallbackResource:nil]; @@ -32,4 +45,55 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( return YES; } +- (void)addDevMenuItemsForBridge:(RCTBridge *)bridge +{ + _memoryStorage = [NSMutableDictionary dictionary]; + + __weak AppDelegate *weakSelf = self; + RCTDevMenuItem *delegateToggle = [RCTDevMenuItem + buttonItemWithTitleBlock:^NSString * { + RNCAsyncStorage *asyncStorage = [bridge moduleForClass:[RNCAsyncStorage class]]; + return asyncStorage.delegate == nil ? @"Set AsyncStorage Delegate" + : @"Unset AsyncStorage Delegate"; + } + handler:^{ + RNCAsyncStorage *asyncStorage = [bridge moduleForClass:[RNCAsyncStorage class]]; + asyncStorage.delegate = asyncStorage.delegate == nil ? weakSelf : nil; + }]; + [bridge.devMenu addItem:delegateToggle]; +} + +- (void)didLoadJavaScript:(NSNotification *)note +{ + RCTBridge *bridge = note.userInfo[@"bridge"]; + if (bridge == nil) { + return; + } + + _bridge = bridge; + [self addDevMenuItemsForBridge:bridge]; +} + +- (BOOL)application:(UIApplication *)app + openURL:(NSURL *)url + options:(NSDictionary *)options +{ + if (![url.scheme isEqualToString:@"rnc-asyncstorage"]) { + return NO; + } + + if ([url.host isEqualToString:@"set-delegate"]) { + if (_testDelegate == nil) { + _testDelegate = [RNCTestAsyncStorageDelegate new]; + } + RNCAsyncStorage *asyncStorage = [_bridge moduleForClass:[RNCAsyncStorage class]]; + asyncStorage.delegate = _testDelegate; + } else if ([url.host isEqualToString:@"unset-delegate"]) { + RNCAsyncStorage *asyncStorage = [_bridge moduleForClass:[RNCAsyncStorage class]]; + asyncStorage.delegate = nil; + } + + return YES; +} + @end diff --git a/example/ios/AsyncStorageExample/Info.plist b/example/ios/AsyncStorageExample/Info.plist index 1c36c0fe..7ac62b4f 100644 --- a/example/ios/AsyncStorageExample/Info.plist +++ b/example/ios/AsyncStorageExample/Info.plist @@ -20,10 +20,36 @@ 1.0 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + org.reactjs.native.example.AsyncStorageExample + CFBundleURLSchemes + + rnc-asyncstorage + + + CFBundleVersion 1 LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + NSLocationWhenInUseUsageDescription UILaunchStoryboardName @@ -40,21 +66,5 @@ UIViewControllerBasedStatusBarAppearance - NSLocationWhenInUseUsageDescription - - NSAppTransportSecurity - - - NSAllowsArbitraryLoads - - NSExceptionDomains - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - - diff --git a/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.h b/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.h new file mode 100644 index 00000000..610e49d3 --- /dev/null +++ b/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.h @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +/*! + * Implementation of |RNCAsyncStorageDelegate| used for E2E testing purposes only. + */ +@interface RNCTestAsyncStorageDelegate : NSObject +@end diff --git a/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.m b/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.m new file mode 100644 index 00000000..14bb8b56 --- /dev/null +++ b/example/ios/AsyncStorageExample/RNCTestAsyncStorageDelegate.m @@ -0,0 +1,112 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RNCTestAsyncStorageDelegate.h" + +const NSInteger RNCTestAsyncStorageExtraValue = 1000000; + +NSString *RNCTestAddExtraValue(NSString *value) +{ + NSInteger i = [value integerValue]; + if (i == 0) { + return value; + } + return [NSString stringWithFormat:@"%ld", (long)i + RNCTestAsyncStorageExtraValue]; +} + +NSString *RNCTestRemoveExtraValue(NSString *value) +{ + NSInteger i = [value integerValue]; + if (i > RNCTestAsyncStorageExtraValue) { + i -= RNCTestAsyncStorageExtraValue; + } + return [NSString stringWithFormat:@"%ld", (long)i]; +} + +@implementation RNCTestAsyncStorageDelegate { + NSMutableDictionary *_memoryStorage; +} + +- (instancetype)init +{ + if (self = [super init]) { + _memoryStorage = [NSMutableDictionary dictionary]; + } + return self; +} + +- (void)allKeys:(RNCAsyncStorageResultCallback)completion +{ + completion(_memoryStorage.allKeys); +} + +- (void)mergeValues:(nonnull NSArray *)values + forKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + NSError *error = nil; + NSDictionary *dictionary = + [NSJSONSerialization JSONObjectWithData:[values[0] dataUsingEncoding:NSUTF8StringEncoding] + options:NSJSONReadingMutableContainers + error:&error]; + NSMutableDictionary *modified = [NSMutableDictionary dictionaryWithCapacity:dictionary.count]; + for (NSString *key in dictionary) { + NSObject *value = dictionary[key]; + if ([value isKindOfClass:[NSString class]]) { + modified[key] = [(NSString *)value stringByAppendingString:@" from delegate"]; + } else { + modified[key] = value; + } + } + + NSData *data = [NSJSONSerialization dataWithJSONObject:modified options:0 error:&error]; + NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + _memoryStorage[keys[0]] = str; + completion(@[str]); +} + +- (void)removeAllValues:(nonnull RNCAsyncStorageCompletion)completion +{ + [_memoryStorage removeAllObjects]; + completion(nil); +} + +- (void)removeValuesForKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + for (NSString *key in keys) { + [_memoryStorage + setObject:[NSString stringWithFormat:@"%ld", (long)RNCTestAsyncStorageExtraValue] + forKey:key]; + } + completion(@[]); +} + +- (void)setValues:(nonnull NSArray *)values + forKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + for (NSUInteger i = 0; i < values.count; ++i) { + NSString *value = values[i]; + NSString *key = keys[i]; + [_memoryStorage setObject:RNCTestRemoveExtraValue(value) forKey:key]; + } + completion(@[]); +} + +- (void)valuesForKeys:(nonnull NSArray *)keys + completion:(nonnull RNCAsyncStorageResultCallback)completion +{ + NSMutableArray *values = [NSMutableArray arrayWithCapacity:keys.count]; + for (NSString *key in keys) { + NSString *value = _memoryStorage[key]; + [values addObject:value == nil ? [NSNull null] : RNCTestAddExtraValue(value)]; + } + completion(values); +} + +@end diff --git a/ios/RNCAsyncStorage.h b/ios/RNCAsyncStorage.h index cfb8c7a1..36c4b7d9 100644 --- a/ios/RNCAsyncStorage.h +++ b/ios/RNCAsyncStorage.h @@ -7,6 +7,7 @@ #import #import +#import /** * A simple, asynchronous, persistent, key-value storage system designed as a @@ -21,6 +22,8 @@ */ @interface RNCAsyncStorage : NSObject +@property (nonatomic, weak, nullable) id delegate; + @property (nonatomic, assign) BOOL clearOnInvalidate; @property (nonatomic, readonly, getter=isValid) BOOL valid; @@ -37,5 +40,4 @@ // Add multiple key value pairs to the cache. - (void)multiSet:(NSArray *> *)kvPairs callback:(RCTResponseSenderBlock)callback; - @end diff --git a/ios/RNCAsyncStorage.m b/ios/RNCAsyncStorage.m index 308bf527..d258f554 100644 --- a/ios/RNCAsyncStorage.m +++ b/ios/RNCAsyncStorage.m @@ -43,6 +43,18 @@ static void RCTAppendError(NSDictionary *error, NSMutableArray * } } +static NSArray *RCTMakeErrors(NSArray> *results) { + NSMutableArray *errors; + for (id object in results) { + if ([object isKindOfClass:[NSError class]]) { + NSError *error = (NSError *)object; + NSDictionary *keyError = RCTMakeError(error.localizedDescription, error, nil); + RCTAppendError(keyError, &errors); + } + } + return errors; +} + static NSString *RCTReadFile(NSString *filePath, NSString *key, NSDictionary **errorOut) { if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) { @@ -329,30 +341,88 @@ - (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL return errorOut; } +- (void)_multiGet:(NSArray *)keys + callback:(RCTResponseSenderBlock)callback + getter:(NSString *(^)(NSUInteger i, NSString *key, NSDictionary **errorOut))getValue +{ + NSMutableArray *errors; + NSMutableArray *> *result = [NSMutableArray arrayWithCapacity:keys.count]; + for (NSUInteger i = 0; i < keys.count; ++i) { + NSString *key = keys[i]; + id keyError; + id value = getValue(i, key, &keyError); + [result addObject:@[key, RCTNullIfNil(value)]]; + RCTAppendError(keyError, &errors); + } + callback(@[RCTNullIfNil(errors), result]); +} + +- (BOOL)_passthroughDelegate +{ + return [self.delegate respondsToSelector:@selector(isPassthrough)] && self.delegate.isPassthrough; +} + #pragma mark - Exported JS Functions RCT_EXPORT_METHOD(multiGet:(NSArray *)keys callback:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + [self.delegate valuesForKeys:keys completion:^(NSArray> *valuesOrErrors) { + [self _multiGet:keys + callback:callback + getter:^NSString *(NSUInteger i, NSString *key, NSDictionary **errorOut) { + id valueOrError = valuesOrErrors[i]; + if ([valueOrError isKindOfClass:[NSError class]]) { + NSError *error = (NSError *)valueOrError; + NSDictionary *extraData = @{@"key": RCTNullIfNil(key)}; + *errorOut = RCTMakeError(error.localizedDescription, error, extraData); + return nil; + } else { + return [valueOrError isKindOfClass:[NSString class]] + ? (NSString *)valueOrError + : nil; + } + }]; + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + NSDictionary *errorOut = [self _ensureSetup]; if (errorOut) { callback(@[@[errorOut], (id)kCFNull]); return; } - NSMutableArray *errors; - NSMutableArray *> *result = [[NSMutableArray alloc] initWithCapacity:keys.count]; - for (NSString *key in keys) { - id keyError; - id value = [self _getValueForKey:key errorOut:&keyError]; - [result addObject:@[key, RCTNullIfNil(value)]]; - RCTAppendError(keyError, &errors); - } - callback(@[RCTNullIfNil(errors), result]); + [self _multiGet:keys + callback:callback + getter:^(NSUInteger i, NSString *key, NSDictionary **errorOut) { + return [self _getValueForKey:key errorOut:errorOut]; + }]; } RCT_EXPORT_METHOD(multiSet:(NSArray *> *)kvPairs callback:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:kvPairs.count]; + NSMutableArray *values = [NSMutableArray arrayWithCapacity:kvPairs.count]; + for (NSArray *entry in kvPairs) { + [keys addObject:entry[0]]; + [values addObject:entry[1]]; + } + [self.delegate setValues:values forKeys:keys completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + NSDictionary *errorOut = [self _ensureSetup]; if (errorOut) { callback(@[@[errorOut]]); @@ -373,6 +443,23 @@ - (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL RCT_EXPORT_METHOD(multiMerge:(NSArray *> *)kvPairs callback:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:kvPairs.count]; + NSMutableArray *values = [NSMutableArray arrayWithCapacity:kvPairs.count]; + for (NSArray *entry in kvPairs) { + [keys addObject:entry[0]]; + [values addObject:entry[1]]; + } + [self.delegate mergeValues:values forKeys:keys completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + NSDictionary *errorOut = [self _ensureSetup]; if (errorOut) { callback(@[@[errorOut]]); @@ -407,8 +494,19 @@ - (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL } RCT_EXPORT_METHOD(multiRemove:(NSArray *)keys - callback:(RCTResponseSenderBlock)callback) + callback:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + [self.delegate removeValuesForKeys:keys completion:^(NSArray> *results) { + NSArray *errors = RCTMakeErrors(results); + callback(@[RCTNullIfNil(errors)]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + NSDictionary *errorOut = [self _ensureSetup]; if (errorOut) { callback(@[@[errorOut]]); @@ -439,6 +537,17 @@ - (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL RCT_EXPORT_METHOD(clear:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + [self.delegate removeAllValues:^(NSError *error) { + NSDictionary *result = nil; + if (error != nil) { + result = RCTMakeError(error.localizedDescription, error, nil); + } + callback(@[RCTNullIfNil(result)]); + }]; + return; + } + [_manifest removeAllObjects]; [RCTGetCache() removeAllObjects]; NSDictionary *error = RCTDeleteStorageDirectory(); @@ -447,6 +556,16 @@ - (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL RCT_EXPORT_METHOD(getAllKeys:(RCTResponseSenderBlock)callback) { + if (self.delegate != nil) { + [self.delegate allKeys:^(NSArray> *keys) { + callback(@[(id)kCFNull, keys]); + }]; + + if (![self _passthroughDelegate]) { + return; + } + } + NSDictionary *errorOut = [self _ensureSetup]; if (errorOut) { callback(@[errorOut, (id)kCFNull]); diff --git a/ios/RNCAsyncStorage.xcodeproj/project.pbxproj b/ios/RNCAsyncStorage.xcodeproj/project.pbxproj index 3bd630c1..c4f0d74f 100644 --- a/ios/RNCAsyncStorage.xcodeproj/project.pbxproj +++ b/ios/RNCAsyncStorage.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1990B97A223993B0009E5EA1 /* RNCAsyncStorage.h in Headers */ = {isa = PBXBuildFile; fileRef = B3E7B5881CC2AC0600A0062D /* RNCAsyncStorage.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 1990B97B223993B0009E5EA1 /* RNCAsyncStorageDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 1990B9402233FE3A009E5EA1 /* RNCAsyncStorageDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; B3E7B58A1CC2AC0600A0062D /* RNCAsyncStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = B3E7B5891CC2AC0600A0062D /* RNCAsyncStorage.m */; }; /* End PBXBuildFile section */ @@ -17,6 +19,8 @@ dstPath = "include/$(PRODUCT_NAME)"; dstSubfolderSpec = 16; files = ( + 1990B97A223993B0009E5EA1 /* RNCAsyncStorage.h in CopyFiles */, + 1990B97B223993B0009E5EA1 /* RNCAsyncStorageDelegate.h in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -24,6 +28,7 @@ /* Begin PBXFileReference section */ 134814201AA4EA6300B7C361 /* libRNCAsyncStorage.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNCAsyncStorage.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 1990B9402233FE3A009E5EA1 /* RNCAsyncStorageDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNCAsyncStorageDelegate.h; sourceTree = ""; }; B3E7B5881CC2AC0600A0062D /* RNCAsyncStorage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNCAsyncStorage.h; sourceTree = ""; }; B3E7B5891CC2AC0600A0062D /* RNCAsyncStorage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNCAsyncStorage.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -52,17 +57,31 @@ children = ( B3E7B5881CC2AC0600A0062D /* RNCAsyncStorage.h */, B3E7B5891CC2AC0600A0062D /* RNCAsyncStorage.m */, + 1990B9402233FE3A009E5EA1 /* RNCAsyncStorageDelegate.h */, 134814211AA4EA7D00B7C361 /* Products */, ); sourceTree = ""; }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + 19F94B1D2239A948006921A9 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 1990B97A223993B0009E5EA1 /* RNCAsyncStorage.h in Headers */, + 1990B97B223993B0009E5EA1 /* RNCAsyncStorageDelegate.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ 58B511DA1A9E6C8500147676 /* RNCAsyncStorage */ = { isa = PBXNativeTarget; buildConfigurationList = 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "RNCAsyncStorage" */; buildPhases = ( + 19F94B1D2239A948006921A9 /* Headers */, 58B511D71A9E6C8500147676 /* Sources */, 58B511D81A9E6C8500147676 /* Frameworks */, 58B511D91A9E6C8500147676 /* CopyFiles */, @@ -204,7 +223,7 @@ isa = XCBuildConfiguration; buildSettings = { HEADER_SEARCH_PATHS = ( - "$(inherited)", + "$(inherited)", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../../../React/**", "$(SRCROOT)/../../react-native/React/**", diff --git a/ios/RNCAsyncStorageDelegate.h b/ios/RNCAsyncStorageDelegate.h new file mode 100644 index 00000000..84e129e1 --- /dev/null +++ b/ios/RNCAsyncStorageDelegate.h @@ -0,0 +1,74 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^RNCAsyncStorageCompletion)(NSError *_Nullable error); +typedef void (^RNCAsyncStorageResultCallback)(NSArray> * valuesOrErrors); + +@protocol RNCAsyncStorageDelegate + +/*! + * Returns all keys currently stored. If none, an empty array is returned. + * @param block Block to call with result. + */ +- (void)allKeys:(RNCAsyncStorageResultCallback)block; + +/*! + * Merges values with the corresponding values stored at specified keys. + * @param values Values to merge. + * @param keys Keys to the values that should be merged with. + * @param block Block to call with merged result. + */ +- (void)mergeValues:(NSArray *)values + forKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; + +/*! + * Removes all values from the store. + * @param block Block to call with result. + */ +- (void)removeAllValues:(RNCAsyncStorageCompletion)block; + +/*! + * Removes all values associated with specified keys. + * @param keys Keys of values to remove. + * @param block Block to call with result. + */ +- (void)removeValuesForKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; + +/*! + * Sets specified key-value pairs. + * @param values Values to set. + * @param keys Keys of specified values to set. + * @param block Block to call with result. + */ +- (void)setValues:(NSArray *)values + forKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; + +/*! + * Returns values associated with specified keys. + * @param keys Keys of values to return. + * @param block Block to call with result. + */ +- (void)valuesForKeys:(NSArray *)keys + completion:(RNCAsyncStorageResultCallback)block; + +@optional + +/*! + * Returns whether the delegate should be treated as a passthrough. + */ +@property (nonatomic, readonly, getter=isPassthrough) BOOL passthrough; + +@end + +NS_ASSUME_NONNULL_END