Accept bools. Add tests. Use datastore

This commit is contained in:
Marcos Rodriguez Velez 2024-11-19 21:25:16 -04:00
parent 1faeb7458b
commit c60bec3b17
13 changed files with 9917 additions and 136 deletions

View File

@ -2,8 +2,7 @@
# react-native-default-preference
Use `SharedPreferences` (Android) and `UserDefaults` (iOS) with React Native over a unified interface.
Use `DataStore` (Android) and `UserDefaults` (iOS) with React Native over a unified interface.
All data is stored as a string. If you need to support more complex data structures, use serialization/deserialization (e.g. JSON).
## Getting started
@ -20,7 +19,6 @@ All data is stored as a string. If you need to support more complex data structu
### Manual installation
#### iOS
1. In XCode, in the project navigator, right click `Libraries``Add Files to [your project's name]`

View File

@ -0,0 +1,112 @@
let mockPreferences = {};
// Helper function to get or create a preferences suite
const getSuite = (name) => {
if (!mockPreferences[name]) {
mockPreferences[name] = {};
}
return mockPreferences[name];
};
const DefaultPreference = {
setName: jest.fn((name) => {
// Mock implementation for setName
return Promise.resolve();
}),
getName: jest.fn((name) => {
// Return the current suite name or null
return Promise.resolve(name || null);
}),
get: jest.fn((name, key) => {
const suite = getSuite(name);
return Promise.resolve(suite.hasOwnProperty(key) ? suite[key] : null);
}),
set: jest.fn((name, key, value) => {
const suite = getSuite(name);
suite[key] = value;
return Promise.resolve();
}),
clear: jest.fn((name, key) => {
const suite = getSuite(name);
delete suite[key];
return Promise.resolve();
}),
getMultiple: jest.fn((name, keys) => {
const suite = getSuite(name);
const values = keys.map(key => (suite.hasOwnProperty(key) ? suite[key] : null));
return Promise.resolve(values);
}),
setMultiple: jest.fn((name, keyValuePairs) => {
const suite = getSuite(name);
Object.entries(keyValuePairs).forEach(([key, value]) => {
suite[key] = value;
});
return Promise.resolve();
}),
clearMultiple: jest.fn((name, keys) => {
const suite = getSuite(name);
keys.forEach(key => delete suite[key]);
return Promise.resolve();
}),
getAll: jest.fn((name) => {
const suite = getSuite(name);
return Promise.resolve({ ...suite });
}),
clearAll: jest.fn((name) => {
mockPreferences[name] = {};
return Promise.resolve();
}),
setDataStore: jest.fn((name, key, value) => {
const suite = getSuite(name);
suite[key] = value;
return Promise.resolve();
}),
clearDataStore: jest.fn((name, key) => {
const suite = getSuite(name);
delete suite[key];
return Promise.resolve();
}),
getMultipleDataStore: jest.fn((name, keys) => {
const suite = getSuite(name);
const values = keys.map(key => (suite.hasOwnProperty(key) ? suite[key] : null));
return Promise.resolve(values);
}),
setMultipleDataStore: jest.fn((name, keyValuePairs) => {
const suite = getSuite(name);
Object.entries(keyValuePairs).forEach(([key, value]) => {
suite[key] = value;
});
return Promise.resolve();
}),
clearMultipleDataStore: jest.fn((name, keys) => {
const suite = getSuite(name);
keys.forEach(key => delete suite[key]);
return Promise.resolve();
}),
getAllDataStore: jest.fn((name) => {
const suite = getSuite(name);
return Promise.resolve({ ...suite });
}),
clearAllDataStore: jest.fn((name) => {
mockPreferences[name] = {};
return Promise.resolve();
}),
};
module.exports = DefaultPreference;

View File

@ -1,4 +1,3 @@
apply plugin: 'com.android.library'
def safeExtGet(prop, fallback) {
@ -6,11 +5,11 @@ def safeExtGet(prop, fallback) {
}
android {
compileSdkVersion safeExtGet('compileSdkVersion', 28)
compileSdkVersion safeExtGet('compileSdkVersion', 30)
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 19)
targetSdkVersion safeExtGet('targetSdkVersion', 28)
minSdkVersion safeExtGet('minSdkVersion', 21)
targetSdkVersion safeExtGet('targetSdkVersion', 30)
versionCode 3
versionName "1.4.2"
ndk {
@ -24,5 +23,6 @@ android {
dependencies {
implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}"
implementation "androidx.datastore:datastore-preferences:1.0.0"
}

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip

View File

@ -1,26 +0,0 @@
{
"name": "react-native-default-preference",
"version": "1.5.0",
"description": "Use SharedPreference (Android) and UserDefaults (iOS) with React Native over a unified interface",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://github.com/kevinresol/react-native-default-preference.git"
},
"keywords": [
"react-native",
"NSUserDefaults",
"user defaults",
"SharedPreferences",
"shared preferences"
],
"author": "kevinresol",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.47.0"
},
"homepage": "https://github.com/kevinresol/react-native-default-preference#readme"
}

View File

@ -1,7 +1,11 @@
package com.kevinresol.react_native_default_preference;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.datastore.core.DataStore;
import androidx.datastore.preferences.core.Preferences;
import androidx.datastore.preferences.core.PreferencesKeys;
import androidx.datastore.preferences.preferencesDataStore;
import androidx.datastore.preferences.core.booleanPreferencesKey;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
@ -10,15 +14,22 @@ import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMapKeySetIterator;
import kotlinx.coroutines.CoroutineScope;
import kotlinx.coroutines.Dispatchers;
import kotlinx.coroutines.launch;
import kotlinx.coroutines.flow.first;
import java.util.Map;
public class RNDefaultPreferenceModule extends ReactContextBaseJavaModule {
private String preferencesName;
private final ReactApplicationContext reactContext;
private final DataStore<Preferences> dataStore;
public RNDefaultPreferenceModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
this.preferencesName = reactContext.getPackageName() + "_preferences";
this.dataStore = reactContext.createDataStore(name = preferencesName);
}
@Override
@ -26,13 +37,12 @@ public class RNDefaultPreferenceModule extends ReactContextBaseJavaModule {
return "RNDefaultPreference";
}
private SharedPreferences getPreferences() {
return getReactApplicationContext().getSharedPreferences(preferencesName, Context.MODE_PRIVATE);
}
@ReactMethod
public void setName(String name, Promise promise) {
this.preferencesName = name;
if (!this.preferencesName.equals(reactContext.getPackageName() + "_preferences")) {
this.preferencesName = name;
this.dataStore = reactContext.createDataStore(name = preferencesName);
}
promise.resolve(null);
}
@ -43,107 +53,138 @@ public class RNDefaultPreferenceModule extends ReactContextBaseJavaModule {
@ReactMethod
public void get(String key, Promise promise) {
Object value = getPreferences().getAll().get(key);
if (value instanceof String) {
promise.resolve((String) value);
} else if (value instanceof Integer) {
promise.resolve((Integer) value);
} else if (value instanceof Boolean) {
promise.resolve((Boolean) value);
} else if (value instanceof Float) {
promise.resolve((Float) value);
} else if (value instanceof Long) {
promise.resolve((Long) value);
} else {
promise.resolve(null);
CoroutineScope(Dispatchers.IO).launch {
Preferences preferences = dataStore.data.first();
Preferences.Key<String> stringKey = PreferencesKeys.stringKey(key);
Preferences.Key<Boolean> booleanKey = PreferencesKeys.booleanKey(key);
String stringValue = preferences[stringKey];
Boolean booleanValue = preferences[booleanKey];
if (stringValue != null) {
promise.resolve(stringValue);
} else if (booleanValue != null) {
promise.resolve(booleanValue);
} else {
promise.resolve(null);
}
}
}
@ReactMethod
public void set(String key, String value, Promise promise) {
getPreferences().edit().putString(key, value).apply();
promise.resolve(null);
CoroutineScope(Dispatchers.IO).launch {
Preferences.Key<String> stringKey = PreferencesKeys.stringKey(key);
dataStore.edit { preferences ->
preferences[stringKey] = value;
}
promise.resolve(null);
}
}
@ReactMethod
public void setBoolean(String key, boolean value, Promise promise) {
CoroutineScope(Dispatchers.IO).launch {
Preferences.Key<Boolean> booleanKey = PreferencesKeys.booleanKey(key);
dataStore.edit { preferences ->
preferences[booleanKey] = value;
}
promise.resolve(null);
}
}
@ReactMethod
public void clear(String key, Promise promise) {
getPreferences().edit().remove(key).apply();
promise.resolve(null);
CoroutineScope(Dispatchers.IO).launch {
Preferences.Key<String> dataStoreKey = PreferencesKeys.stringKey(key);
dataStore.edit { preferences ->
preferences.remove(dataStoreKey);
}
promise.resolve(null);
}
}
@ReactMethod
public void getMultiple(ReadableArray keys, Promise promise) {
WritableArray result = Arguments.createArray();
for(int i = 0; i < keys.size(); i++) {
String key = keys.getString(i);
Object value = getPreferences().getAll().get(key);
if (value instanceof String) {
result.pushString((String) value);
} else if (value instanceof Integer) {
result.pushInt((Integer) value);
} else if (value instanceof Boolean) {
result.pushBoolean((Boolean) value);
} else if (value instanceof Float) {
result.pushDouble((Float) value);
} else if (value instanceof Long) {
result.pushDouble((Long) value);
} else {
result.pushNull();
CoroutineScope(Dispatchers.IO).launch {
WritableArray result = Arguments.createArray();
Preferences preferences = dataStore.data.first();
for (int i = 0; i < keys.size(); i++) {
String key = keys.getString(i);
Preferences.Key<String> stringKey = PreferencesKeys.stringKey(key);
Preferences.Key<Boolean> booleanKey = PreferencesKeys.booleanKey(key);
String stringValue = preferences[stringKey];
Boolean booleanValue = preferences[booleanKey];
if (stringValue != null) {
result.pushString(stringValue);
} else if (booleanValue != null) {
result.pushBoolean(booleanValue);
} else {
result.pushNull();
}
}
promise.resolve(result);
}
promise.resolve(result);
}
@ReactMethod
public void setMultiple(ReadableMap data, Promise promise) {
SharedPreferences.Editor editor = getPreferences().edit();
ReadableMapKeySetIterator iter = data.keySetIterator();
while(iter.hasNextKey()) {
String key = iter.nextKey();
editor.putString(key, data.getString(key));
CoroutineScope(Dispatchers.IO).launch {
dataStore.edit { preferences ->
ReadableMapKeySetIterator iter = data.keySetIterator();
while (iter.hasNextKey()) {
String key = iter.nextKey();
if (data.getType(key) == ReadableType.String) {
Preferences.Key<String> stringKey = PreferencesKeys.stringKey(key);
preferences[stringKey] = data.getString(key);
} else if (data.getType(key) == ReadableType.Boolean) {
Preferences.Key<Boolean> booleanKey = PreferencesKeys.booleanKey(key);
preferences[booleanKey] = data.getBoolean(key);
}
}
}
promise.resolve(null);
}
editor.apply();
promise.resolve(null);
}
@ReactMethod
public void clearMultiple(ReadableArray keys, Promise promise) {
SharedPreferences.Editor editor = getPreferences().edit();
for(int i = 0; i < keys.size(); i++) {
editor.remove(keys.getString(i));
CoroutineScope(Dispatchers.IO).launch {
dataStore.edit { preferences ->
for (int i = 0; i < keys.size(); i++) {
Preferences.Key<String> dataStoreKey = PreferencesKeys.stringKey(keys.getString(i));
preferences.remove(dataStoreKey);
}
}
promise.resolve(null);
}
editor.apply();
promise.resolve(null);
}
@ReactMethod
public void getAll(Promise promise) {
WritableMap result = Arguments.createMap();
Map<String, ?> allEntries = getPreferences().getAll();
for (Map.Entry<String, ?> entry : allEntries.entrySet()) {
Object value = entry.getValue();
if (value instanceof String) {
result.putString(entry.getKey(), (String) value);
} else if (value instanceof Integer) {
result.putInt(entry.getKey(), (Integer) value);
} else if (value instanceof Boolean) {
result.putBoolean(entry.getKey(), (Boolean) value);
} else if (value instanceof Float) {
result.putDouble(entry.getKey(), ((Float) value).doubleValue());
} else if (value instanceof Long) {
result.putDouble(entry.getKey(), ((Long) value).doubleValue());
} else {
result.putNull(entry.getKey());
CoroutineScope(Dispatchers.IO).launch {
WritableMap result = Arguments.createMap();
Preferences preferences = dataStore.data.first();
for (Map.Entry<String, ?> entry : preferences.asMap().entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (value instanceof String) {
result.putString(key, (String) value);
} else if (value instanceof Boolean) {
result.putBoolean(key, (Boolean) value);
} else {
result.putNull(key);
}
}
promise.resolve(result);
}
promise.resolve(result);
}
@ReactMethod
public void clearAll(Promise promise) {
SharedPreferences.Editor editor = getPreferences().edit();
editor.clear();
editor.apply();
promise.resolve(null);
CoroutineScope(Dispatchers.IO).launch {
dataStore.edit { preferences ->
preferences.clear();
}
promise.resolve(null);
}
}
}

125
index.test.ts Normal file
View File

@ -0,0 +1,125 @@
import { NativeModules } from 'react-native';
import DefaultPreference from './index';
const mockDefaultPreference = require('./__mocks__/react-native-default-preference');
const { RNDefaultPreference } = NativeModules;
jest.mock('react-native', () => {
const mockDefaultPreference = require('./__mocks__/react-native-default-preference');
return {
NativeModules: {
RNDefaultPreference: {
setName: mockDefaultPreference.setName,
getName: mockDefaultPreference.getName,
get: mockDefaultPreference.get,
set: mockDefaultPreference.set,
clear: mockDefaultPreference.clear,
getMultiple: mockDefaultPreference.getMultiple,
setMultiple: mockDefaultPreference.setMultiple,
clearMultiple: mockDefaultPreference.clearMultiple,
getAll: mockDefaultPreference.getAll,
clearAll: mockDefaultPreference.clearAll,
setDataStore: mockDefaultPreference.setDataStore,
clearDataStore: mockDefaultPreference.clearDataStore,
getMultipleDataStore: mockDefaultPreference.getMultipleDataStore,
setMultipleDataStore: mockDefaultPreference.setMultipleDataStore,
clearMultipleDataStore: mockDefaultPreference.clearMultipleDataStore,
getAllDataStore: mockDefaultPreference.getAllDataStore,
clearAllDataStore: mockDefaultPreference.clearAllDataStore,
},
},
Platform: {
OS: 'ios',
},
};
});
describe('DefaultPreference', () => {
let defaultPref: DefaultPreference;
beforeEach(() => {
jest.clearAllMocks();
// Reset mockPreferences by clearing all data for 'default' suite
RNDefaultPreference.clearAll('default');
defaultPref = new DefaultPreference();
});
it('should set and get a value for the default instance', async () => {
await defaultPref.set('key1', 'value1');
const value = await defaultPref.get('key1');
expect(value).toBe('value1');
});
it('should set and get a value for the group instance', async () => {
const groupPref = new DefaultPreference('group.reactnative.example');
await groupPref.set('key1', 'value1');
const value = await groupPref.get('key1');
expect(value).toBe('value1');
});
it('should clear a value for the default instance', async () => {
await defaultPref.set('key2', 'value2');
await defaultPref.clear('key2');
const value = await defaultPref.get('key2');
expect(value).toBeNull();
});
it('should clear a value for the group instance', async () => {
const groupPref = new DefaultPreference('group.reactnative.example');
await groupPref.set('key2', 'value2');
await groupPref.clear('key2');
const value = await groupPref.get('key2');
expect(value).toBeNull();
});
it('should set and get multiple values for the default instance', async () => {
const data = { key3: 'value3', key4: 'value4' };
await defaultPref.setMultiple(data);
const values = await defaultPref.getMultiple(['key3', 'key4']);
expect(values).toEqual(['value3', 'value4']);
});
it('should clear multiple values for the default instance', async () => {
const data = { key5: 'value5', key6: 'value6' };
await defaultPref.setMultiple(data);
await defaultPref.clearMultiple(['key5', 'key6']);
const values = await defaultPref.getMultiple(['key5', 'key6']);
expect(values).toEqual([null, null]);
});
it('should get all values for the default instance', async () => {
const data = { key7: 'value7', key8: 'value8' };
await defaultPref.setMultiple(data);
const allValues = await defaultPref.getAll();
expect(allValues).toEqual(data);
});
it('should clear all values for the default instance', async () => {
const data = { key9: 'value9', key10: 'value10' };
await defaultPref.setMultiple(data);
await defaultPref.clearAll();
const allValues = await defaultPref.getAll();
expect(allValues).toEqual({});
});
it('should not set the name if no name is provided', () => {
const instance = new DefaultPreference();
expect(RNDefaultPreference.setName).not.toHaveBeenCalled();
});
it('should set the name if a name is provided', () => {
const instance = new DefaultPreference('group.reactnative.example');
expect(RNDefaultPreference.setName).toHaveBeenCalledWith('group.reactnative.example');
});
it('should not call setName if name is default', () => {
new DefaultPreference();
expect(RNDefaultPreference.setName).not.toHaveBeenCalled();
});
it('should call setName if name is not default', () => {
new DefaultPreference('customName');
expect(RNDefaultPreference.setName).toHaveBeenCalledWith('customName');
});
});

View File

@ -1,4 +1,4 @@
import { NativeModules } from 'react-native';
import { NativeModules, Platform } from 'react-native';
const { RNDefaultPreference } = NativeModules;
@ -7,44 +7,73 @@ export interface RNDefaultPreferenceKeys {
}
class DefaultPreference {
static async get(key: string): Promise<string | number | boolean | null> {
return RNDefaultPreference.get(key);
private readonly name: string;
constructor(name: string = 'default') {
this.name = name;
if (name !== 'default') {
RNDefaultPreference.setName(name);
}
}
static async set(key: string, value: string | number | boolean): Promise<void> {
return RNDefaultPreference.set(key, value);
async get(key: string): Promise<string | number | boolean | null> {
if (Platform.OS === 'android') {
return RNDefaultPreference.getDataStore(this.name, key);
}
return RNDefaultPreference.get(this.name, key);
}
static async clear(key: string): Promise<void> {
return RNDefaultPreference.clear(key);
async set(key: string, value: string | number | boolean): Promise<void> {
if (Platform.OS === 'android') {
return RNDefaultPreference.setDataStore(this.name, key, value);
}
return RNDefaultPreference.set(this.name, key, value);
}
static async getMultiple(keys: string[]): Promise<(string | number | boolean | null)[]> {
return RNDefaultPreference.getMultiple(keys);
async clear(key: string): Promise<void> {
if (Platform.OS === 'android') {
return RNDefaultPreference.clearDataStore(this.name, key);
}
return RNDefaultPreference.clear(this.name, key);
}
static async setMultiple(data: RNDefaultPreferenceKeys): Promise<void> {
return RNDefaultPreference.setMultiple(data);
async getMultiple(keys: string[]): Promise<(string | number | boolean | null)[]> {
if (Platform.OS === 'android') {
return RNDefaultPreference.getMultipleDataStore(this.name, keys);
}
return RNDefaultPreference.getMultiple(this.name, keys);
}
static async clearMultiple(keys: string[]): Promise<void> {
return RNDefaultPreference.clearMultiple(keys);
async setMultiple(data: RNDefaultPreferenceKeys): Promise<void> {
if (Platform.OS === 'android') {
return RNDefaultPreference.setMultipleDataStore(this.name, data);
}
return RNDefaultPreference.setMultiple(this.name, data);
}
static async getAll(): Promise<RNDefaultPreferenceKeys> {
return RNDefaultPreference.getAll();
async clearMultiple(keys: string[]): Promise<void> {
if (Platform.OS === 'android') {
return RNDefaultPreference.clearMultipleDataStore(this.name, keys);
}
return RNDefaultPreference.clearMultiple(this.name, keys);
}
static async clearAll(): Promise<void> {
return RNDefaultPreference.clearAll();
async getAll(): Promise<RNDefaultPreferenceKeys> {
if (Platform.OS === 'android') {
return RNDefaultPreference.getAllDataStore(this.name);
}
return RNDefaultPreference.getAll(this.name);
}
static async getName(): Promise<string> {
return RNDefaultPreference.getName();
async clearAll(): Promise<void> {
if (Platform.OS === 'android') {
return RNDefaultPreference.clearAllDataStore(this.name);
}
return RNDefaultPreference.clearAll(this.name);
}
static async setName(name: string): Promise<void> {
return RNDefaultPreference.setName(name);
async getName(): Promise<string> {
return this.name;
}
}

View File

@ -24,7 +24,9 @@ RCT_EXPORT_METHOD(setName:(NSString *)name
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
defaultSuiteName = name;
if (defaultSuiteName == nil) {
defaultSuiteName = name;
}
resolve([NSNull null]);
}

15
jest.config.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
transformIgnorePatterns: [
'/node_modules/',
],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
};

9473
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
{
"name": "react-native-default-preference",
"version": "1.5.0",
"description": "Use SharedPreference (Android) and UserDefaults (iOS) with React Native over a unified interface",
"version": "1.5.1",
"description": "Use DataStore (Android) and UserDefaults (iOS) with React Native over a unified interface",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "jest"
},
"repository": {
"type": "git",
@ -14,13 +14,17 @@
"react-native",
"NSUserDefaults",
"user defaults",
"SharedPreferences",
"shared preferences"
"DataStore",
"data store"
],
"author": "kevinresol",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.47.0"
},
"homepage": "https://github.com/kevinresol/react-native-default-preference#readme"
"homepage": "https://github.com/kevinresol/react-native-default-preference#readme",
"devDependencies": {
"ts-jest": "^27.0.0",
"@types/jest": "^27.0.0"
}
}

8
tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
// ...existing config...
"esModuleInterop": true,
// ...existing config...
},
// ...existing config...
}