diff --git a/.circleci/config.yml b/.circleci/config.yml index b9fc50ef..3d552901 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -52,7 +52,7 @@ cache keys: brew ios: &key_brew_ios cache-brew-ios-v4-{{ arch }} brew android: &key_brew_android cache-brew-android-v4-{{ arch }} yarn: &key_yarn cache-yarn-{{ checksum "package.json" }}-{{ arch }} - gradle: &key_gradle cache-gradle-{{ checksum "example/android/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "package.json" }}-{{ arch }} + gradle: &key_gradle cache-gradle-v1-{{ checksum "example/android/gradle/wrapper/gradle-wrapper.properties" }}-{{ checksum "package.json" }}-{{ arch }} pods: &key_pods cache-pods-v1-{{ checksum "example/ios/Podfile" }}-{{ checksum "package.json" }}-{{ arch }} cache: @@ -151,6 +151,26 @@ jobs: name: Flow check command: yarn test:flow + "Test: Android unit": + <<: *android_defaults + steps: + - *addWorkspace + - restore-cache: *cache_restore_yarn + - run: + name: Installing Yarn dependencies + command: yarn --pure-lockfile --non-interactive --cache-folder ~/.cache/yarn + - save-cache: *cache_save_yarn + - restore-cache: *cache_restore_gradle + - run: + name: Downloading Gradle dependencies + working_directory: example/android + command: ./gradlew --max-workers 2 fetchDependencies + - save-cache: *cache_save_gradle + - run: + name: Next storage tests + working_directory: example/android + command: ./gradlew react-native-async-storage_async-storage:test + "Test: iOS e2e": <<: *macos_defaults steps: @@ -192,23 +212,16 @@ jobs: <<: *android_defaults steps: - *addWorkspace - - restore-cache: *cache_restore_yarn - - run: - name: Installing Yarn dependencies - command: yarn --pure-lockfile --non-interactive --cache-folder ~/.cache/yarn - - save-cache: *cache_save_yarn - - restore-cache: *cache_restore_gradle - - run: - name: Downloading Gradle dependencies - working_directory: example/android - command: ./gradlew --max-workers 2 fetchDependencies - - save-cache: *cache_save_gradle - run: name: Bundle JS command: yarn bundle:android --dev false - run: - name: Build APK + name: Build APKs command: yarn build:e2e:android + - run: + name: Build APK with Next storage + working_directory: example/android + command: ./gradlew assembleNext --max-workers 2 - persist_to_workspace: root: ~/async_storage paths: @@ -272,7 +285,7 @@ jobs: -no-snapshot \ -no-boot-anim \ -no-window \ - -logcat '*:W' | grep -i "ReactNative" + -logcat '*:E ReactNative:W ReactNativeJS:*' - run: name: Wait for emulator to boot command: ./scripts/android_e2e.sh 'wait_for_emulator' @@ -283,6 +296,12 @@ jobs: - run: name: Run e2e tests command: yarn test:e2e:android + - run: + name: Clear previous app from device + command: adb uninstall com.microsoft.reacttestapp + - run: + name: Run e2e tests for Next storage + command: yarn detox test -c android.emu.release.next --maxConcurrency 1 Release: <<: *js_defaults @@ -306,6 +325,9 @@ workflows: - "Test: flow": requires: - "Setup environment" + - "Test: Android unit": + requires: + - "Setup environment" - "Test: iOS e2e": requires: - "Test: lint" @@ -314,6 +336,7 @@ workflows: requires: - "Test: lint" - "Test: flow" + - "Test: Android unit" - "Test: Android e2e": requires: - "Build: Android release apk" diff --git a/android/build.gradle b/android/build.gradle index d1442125..e806b778 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -21,18 +21,30 @@ def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } +def getFlagOrDefault(flagName, defaultValue) { + rootProject.hasProperty(flagName) ? rootProject.properties[flagName] == "true" : defaultValue +} + configurations { compileClasspath } buildscript { - if (project == rootProject) { - repositories { - google() - jcenter() - } - dependencies { + // kotlin version is dictated by rootProject extension or property in gradle.properties + ext.asyncStorageKtVersion = rootProject.ext.has('kotlinVersion') + ? rootProject.ext['kotlinVersion'] + : rootProject.hasProperty('AsyncStorage_kotlinVersion') + ? rootProject.properties['AsyncStorage_kotlinVersion'] + : '1.4.21' + + repositories { + google() + jcenter() + } + dependencies { + if (project == rootProject) { classpath 'com.android.tools.build:gradle:3.6.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$asyncStorageKtVersion" } } } @@ -42,18 +54,23 @@ buildscript { // This also protects the database from filling up the disk cache and becoming malformed. // If you really need bigger size, please keep in mind the potential consequences. long dbSizeInMB = 6L - def newDbSize = rootProject.properties['AsyncStorage_db_size_in_MB'] - -if( newDbSize != null && newDbSize.isLong()) { +if (newDbSize != null && newDbSize.isLong()) { dbSizeInMB = newDbSize.toLong() } -def useDedicatedExecutor = rootProject.hasProperty('AsyncStorage_dedicatedExecutor') - ? rootProject.properties['AsyncStorage_dedicatedExecutor'] - : false +// Instead of reusing AsyncTask thread pool, AsyncStorage can use its own executor +def useDedicatedExecutor = getFlagOrDefault('AsyncStorage_dedicatedExecutor', false) + +// Use next storage implementation +def useNextStorage = getFlagOrDefault("AsyncStorage_useNextStorage", false) apply plugin: 'com.android.library' +if (useNextStorage) { + apply plugin: 'kotlin-android' + apply plugin: 'kotlin-kapt' + apply from: './testresults.gradle' +} android { compileSdkVersion safeExtGet('compileSdkVersion', 28) @@ -62,11 +79,18 @@ android { minSdkVersion safeExtGet('minSdkVersion', 19) targetSdkVersion safeExtGet('targetSdkVersion', 28) buildConfigField "Long", "AsyncStorage_db_size", "${dbSizeInMB}L" - buildConfigField("boolean", "AsyncStorage_useDedicatedExecutor", "${useDedicatedExecutor}") + buildConfigField "boolean", "AsyncStorage_useDedicatedExecutor", "${useDedicatedExecutor}" + buildConfigField "boolean", "AsyncStorage_useNextStorage", "${useNextStorage}" } lintOptions { abortOnError false } + + if (useNextStorage) { + testOptions { + unitTests.returnDefaultValues = true + } + } } repositories { @@ -79,6 +103,30 @@ repositories { } dependencies { + + if (useNextStorage) { + def room_version = "2.2.6" + def coroutines_version = "1.4.2" + def junit_version = "4.12" + def robolectric_version = "4.5.1" + def truth_version = "1.1.2" + def androidxtest_version = "1.1.0" + def coroutinesTest_version = "1.4.2" + + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-ktx:$room_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + kapt "androidx.room:room-compiler:$room_version" + + testImplementation "junit:junit:$junit_version" + testImplementation "androidx.test:runner:$androidxtest_version" + testImplementation "androidx.test:rules:$androidxtest_version" + testImplementation "androidx.test.ext:junit:$androidxtest_version" + testImplementation "org.robolectric:robolectric:$robolectric_version" + testImplementation "com.google.truth:truth:$truth_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesTest_version" + } + //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' // From node_modules } diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..2d8d1e4d --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true \ No newline at end of file diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStorageModule.java b/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStorageModule.java index 9936ee50..cee4b82b 100644 --- a/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStorageModule.java +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStorageModule.java @@ -46,37 +46,6 @@ public final class AsyncStorageModule private ReactDatabaseSupplier mReactDatabaseSupplier; private boolean mShuttingDown = false; - // Adapted from https://android.googlesource.com/platform/frameworks/base.git/+/1488a3a19d4681a41fb45570c15e14d99db1cb66/core/java/android/os/AsyncTask.java#237 - private class SerialExecutor implements Executor { - private final ArrayDeque mTasks = new ArrayDeque(); - private Runnable mActive; - private final Executor executor; - - SerialExecutor(Executor executor) { - this.executor = executor; - } - - public synchronized void execute(final Runnable r) { - mTasks.offer(new Runnable() { - public void run() { - try { - r.run(); - } finally { - scheduleNext(); - } - } - }); - if (mActive == null) { - scheduleNext(); - } - } - synchronized void scheduleNext() { - if ((mActive = mTasks.poll()) != null) { - executor.execute(mActive); - } - } - } - private final SerialExecutor executor; public AsyncStorageModule(ReactApplicationContext reactContext) { diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStoragePackage.java b/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStoragePackage.java index 25eb5a14..c110122c 100644 --- a/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStoragePackage.java +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/AsyncStoragePackage.java @@ -7,30 +7,51 @@ package com.reactnativecommunity.asyncstorage; +import android.util.Log; import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.uimanager.ViewManager; - -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.List; public class AsyncStoragePackage implements ReactPackage { @Override public List createNativeModules(ReactApplicationContext reactContext) { - return Arrays.asList(new AsyncStorageModule(reactContext)); + + List moduleList = new ArrayList<>(1); + + if (BuildConfig.AsyncStorage_useNextStorage) { + try { + Class storageClass = Class.forName("com.reactnativecommunity.asyncstorage.next.StorageModule"); + NativeModule inst = (NativeModule) storageClass.getDeclaredConstructor(new Class[]{ReactContext.class}).newInstance(reactContext); + moduleList.add(inst); + } catch (Exception e) { + String message = "Something went wrong when initializing module:" + + "\n" + + e.getCause().getClass() + + "\n" + + "Cause:" + e.getCause().getLocalizedMessage(); + Log.e("AsyncStorage_Next", message); + } + } else { + moduleList.add(new AsyncStorageModule(reactContext)); + } + + return moduleList; } // Deprecated in RN 0.47 public List> createJSModules() { - return Collections.emptyList(); + return Collections.emptyList(); } @Override @SuppressWarnings("rawtypes") public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); + return Collections.emptyList(); } } \ No newline at end of file diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/SerialExecutor.java b/android/src/main/java/com/reactnativecommunity/asyncstorage/SerialExecutor.java new file mode 100644 index 00000000..bf46c1e7 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/SerialExecutor.java @@ -0,0 +1,40 @@ +package com.reactnativecommunity.asyncstorage; + +import java.util.ArrayDeque; +import java.util.concurrent.Executor; + +/** + * Detox is using this implementation detail in its environment setup, + * so in order for Next storage to work, this class has been made public + * + * Adapted from https://android.googlesource.com/platform/frameworks/base.git/+/1488a3a19d4681a41fb45570c15e14d99db1cb66/core/java/android/os/AsyncTask.java#237 + */ +public class SerialExecutor implements Executor { + private final ArrayDeque mTasks = new ArrayDeque(); + private Runnable mActive; + private final Executor executor; + + public SerialExecutor(Executor executor) { + this.executor = executor; + } + + public synchronized void execute(final Runnable r) { + mTasks.offer(new Runnable() { + public void run() { + try { + r.run(); + } finally { + scheduleNext(); + } + } + }); + if (mActive == null) { + scheduleNext(); + } + } + synchronized void scheduleNext() { + if ((mActive = mTasks.poll()) != null) { + executor.execute(mActive); + } + } +} diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpers.kt b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpers.kt new file mode 100644 index 00000000..bb613774 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpers.kt @@ -0,0 +1,86 @@ +package com.reactnativecommunity.asyncstorage.next + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import org.json.JSONException +import org.json.JSONObject + +fun ReadableArray.toEntryList(): List { + val list = mutableListOf() + for (keyValue in this.toArrayList()) { + if (keyValue !is ArrayList<*> || keyValue.size != 2) { + throw AsyncStorageError.invalidKeyValueFormat() + } + val key = keyValue[0] + val value = keyValue[1] + + if (key !is String) { + when (key) { + null -> throw AsyncStorageError.keyIsNull() + else -> throw AsyncStorageError.keyNotString() + } + } + + if (value !is String) { + throw AsyncStorageError.valueNotString(key) + } + + list.add(Entry(key, value)) + } + return list +} + +fun ReadableArray.toKeyList(): List { + val list = this.toArrayList() + + for (item in list) { + if (item !is String) { + throw AsyncStorageError.keyNotString() + } + } + return list as List +} + +fun List.toKeyValueArgument(): ReadableArray { + val args = Arguments.createArray() + + for (entry in this) { + val keyValue = Arguments.createArray() + keyValue.pushString(entry.key) + keyValue.pushString(entry.value) + args.pushArray(keyValue) + } + + return args +} + +fun String?.isValidJson(): Boolean { + if (this == null) return false + + return try { + JSONObject(this) + true + } catch (e: JSONException) { + false + } +} + +fun JSONObject.mergeWith(newObject: JSONObject): JSONObject { + + val keys = newObject.keys() + val mergedObject = JSONObject(this.toString()) + + while (keys.hasNext()) { + val key = keys.next() + val curValue = this.optJSONObject(key) + val newValue = newObject.optJSONObject(key) + + if (curValue != null && newValue != null) { + val merged = curValue.mergeWith(newValue) + mergedObject.put(key, merged) + } else { + mergedObject.put(key, newObject.get(key)) + } + } + return mergedObject +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ErrorHelpers.kt b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ErrorHelpers.kt new file mode 100644 index 00000000..59549b37 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/ErrorHelpers.kt @@ -0,0 +1,39 @@ +package com.reactnativecommunity.asyncstorage.next + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Callback +import kotlinx.coroutines.CoroutineExceptionHandler + +internal fun createExceptionHandler(cb: Callback): CoroutineExceptionHandler { + return CoroutineExceptionHandler { _, throwable -> + val error = Arguments.createMap() + if (throwable !is AsyncStorageError) { + error.putString( + "message", "Unexpected AsyncStorage error: ${throwable.localizedMessage}" + ) + } else { + error.putString("message", throwable.errorMessage) + } + + cb(error) + } +} + +internal class AsyncStorageError private constructor(val errorMessage: String) : + Throwable(errorMessage) { + + companion object { + fun keyIsNull() = AsyncStorageError("Key cannot be null.") + + fun keyNotString() = AsyncStorageError("Provided key is not string. Only strings are supported as storage key.") + + fun valueNotString(key: String?): AsyncStorageError { + val detail = if (key == null) "Provided value" else "Value for key \"$key\"" + return AsyncStorageError("$detail is not a string. Only strings are supported as a value.") + } + + fun invalidKeyValueFormat() = + AsyncStorageError("Invalid key-value format. Expected a list of [key, value] list.") + + } +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageModule.kt b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageModule.kt new file mode 100644 index 00000000..ade13167 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageModule.kt @@ -0,0 +1,90 @@ +package com.reactnativecommunity.asyncstorage.next + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import com.reactnativecommunity.asyncstorage.SerialExecutor +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.launch + +class StorageModule(reactContext: ReactContext) : ReactContextBaseJavaModule(), CoroutineScope { + override fun getName() = "RNC_AsyncSQLiteDBStorage" + + // this executor is not used by the module, but it must exists here due to + // Detox relying on this implementation detail to run + @VisibleForTesting + private val executor = SerialExecutor(Dispatchers.Main.asExecutor()) + + override val coroutineContext = + Dispatchers.IO + CoroutineName("AsyncStorageScope") + SupervisorJob() + + private val storage = StorageSupplier.getInstance(reactContext) + + companion object { + @JvmStatic + fun getStorageInstance(ctx: Context): AsyncStorageAccess { + return StorageSupplier.getInstance(ctx) + } + } + + @ReactMethod + fun multiGet(keys: ReadableArray, cb: Callback) { + launch(createExceptionHandler(cb)) { + val entries = storage.getValues(keys.toKeyList()) + cb(null, entries.toKeyValueArgument()) + } + } + + @ReactMethod + fun multiSet(keyValueArray: ReadableArray, cb: Callback) { + launch(createExceptionHandler(cb)) { + val entries = keyValueArray.toEntryList() + storage.setValues(entries) + cb(null) + } + } + + @ReactMethod + fun multiRemove(keys: ReadableArray, cb: Callback) { + launch(createExceptionHandler(cb)) { + storage.removeValues(keys.toKeyList()) + cb(null) + } + } + + @ReactMethod + fun multiMerge(keyValueArray: ReadableArray, cb: Callback) { + launch(createExceptionHandler(cb)) { + val entries = keyValueArray.toEntryList() + storage.mergeValues(entries) + cb(null) + } + } + + @ReactMethod + fun getAllKeys(cb: Callback) { + launch(createExceptionHandler(cb)) { + val keys = storage.getKeys() + val result = Arguments.createArray() + keys.forEach { result.pushString(it) } + cb.invoke(null, result) + } + } + + @ReactMethod + fun clear(cb: Callback) { + launch(createExceptionHandler(cb)) { + storage.clear() + cb(null) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageSupplier.kt b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageSupplier.kt new file mode 100644 index 00000000..e4bdc866 --- /dev/null +++ b/android/src/main/java/com/reactnativecommunity/asyncstorage/next/StorageSupplier.kt @@ -0,0 +1,159 @@ +package com.reactnativecommunity.asyncstorage.next + +import android.content.Context +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.json.JSONObject + +private const val DATABASE_VERSION = 2 +private const val DATABASE_NAME = "AsyncStorage" +private const val TABLE_NAME = "Storage" +private const val COLUMN_KEY = "key" +private const val COLUMN_VALUE = "value" + + +@Entity(tableName = TABLE_NAME) +data class Entry( + @PrimaryKey @ColumnInfo(name = COLUMN_KEY) val key: String, + @ColumnInfo(name = COLUMN_VALUE) val value: String? +) + +@Dao +internal interface StorageDao { + + @Transaction + @Query("SELECT * FROM $TABLE_NAME WHERE `$COLUMN_KEY` IN (:keys)") + suspend fun getValues(keys: List): List + + @Transaction + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setValues(entries: List) + + @Transaction + @Query("DELETE FROM $TABLE_NAME WHERE `$COLUMN_KEY` in (:keys)") + suspend fun removeValues(keys: List) + + @Transaction + suspend fun mergeValues(entries: List) { + val currentDbEntries = getValues(entries.map { it.key }) + val newEntries = mutableListOf() + + entries.forEach { newEntry -> + val oldEntry = currentDbEntries.find { it.key == newEntry.key } + if (oldEntry?.value == null) { + newEntries.add(newEntry) + } else if (!oldEntry.value.isValidJson() || !newEntry.value.isValidJson()) { + newEntries.add(newEntry) + } else { + val newValue = + JSONObject(oldEntry.value).mergeWith(JSONObject(newEntry.value)).toString() + newEntries.add(newEntry.copy(value = newValue)) + } + } + setValues(newEntries) + } + + @Transaction + @Query("SELECT `$COLUMN_KEY` FROM $TABLE_NAME") + suspend fun getKeys(): List + + @Transaction + @Query("DELETE FROM $TABLE_NAME") + suspend fun clear() +} + + +/** + * Previous version of AsyncStorage is violating the SQL standard (based on bug in SQLite), + * where PrimaryKey ('key' column) should never be null (https://www.sqlite.org/lang_createtable.html#the_primary_key). + * Because of that, we cannot reuse the old DB, because ROOM is guarded against that case (won't compile). + * + * In order to work around this, two steps are necessary: + * - Room DB pre-population from the old database file (https://developer.android.com/training/data-storage/room/prepopulate#from-asset) + * - Version migration, so that we can mark 'key' column as NOT-NULL + * + * This migration will happens only once, when developer enable this feature (when DB is still not created). + */ +@Suppress("ClassName") +private object MIGRATION_TO_NEXT : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + val oldTableName = "catalystLocalStorage" // from ReactDatabaseSupplier + database.execSQL("CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`${COLUMN_KEY}` TEXT NOT NULL, `${COLUMN_VALUE}` TEXT, PRIMARY KEY(`${COLUMN_KEY}`));") + // even if the old AsyncStorage has checks for not nullable keys + // make sure we don't copy any, to not fail migration + database.execSQL("DELETE FROM $oldTableName WHERE `${COLUMN_KEY}` IS NULL") + database.execSQL( + """ + INSERT INTO $TABLE_NAME (`${COLUMN_KEY}`, `${COLUMN_VALUE}`) + SELECT `${COLUMN_KEY}`, `${COLUMN_VALUE}` + FROM $oldTableName; + """.trimIndent() + ) + } +} + +@Database(entities = [Entry::class], version = DATABASE_VERSION, exportSchema = true) +internal abstract class StorageDb : RoomDatabase() { + abstract fun storage(): StorageDao + + companion object { + private var instance: StorageDb? = null + + fun getDatabase(context: Context): StorageDb { + var inst = instance + if (inst != null) { + return inst + } + synchronized(this) { + val oldDbFile = context.getDatabasePath("RKStorage") + val db = Room.databaseBuilder( + context, StorageDb::class.java, DATABASE_NAME + ) + if (oldDbFile.exists()) { + // migrate data from old database, if it exists + db.createFromFile(oldDbFile).addMigrations(MIGRATION_TO_NEXT) + } + inst = db.build() + instance = inst + return instance!! + } + } + } +} + +interface AsyncStorageAccess { + suspend fun getValues(keys: List): List + suspend fun setValues(entries: List) + suspend fun removeValues(keys: List) + suspend fun getKeys(): List + suspend fun clear() + suspend fun mergeValues(entries: List) +} + +class StorageSupplier internal constructor(db: StorageDb) : AsyncStorageAccess { + companion object { + fun getInstance(ctx: Context): AsyncStorageAccess { + return StorageSupplier(StorageDb.getDatabase(ctx)) + } + } + + private val access = db.storage() + + override suspend fun getValues(keys: List) = access.getValues(keys) + override suspend fun setValues(entries: List) = access.setValues(entries) + override suspend fun removeValues(keys: List) = access.removeValues(keys) + override suspend fun mergeValues(entries: List) = access.mergeValues(entries) + override suspend fun getKeys() = access.getKeys() + override suspend fun clear() = access.clear() +} \ No newline at end of file diff --git a/android/src/test/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpersTest.kt b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpersTest.kt new file mode 100644 index 00000000..34238ea7 --- /dev/null +++ b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/ArgumentHelpersTest.kt @@ -0,0 +1,79 @@ +package com.reactnativecommunity.asyncstorage.next + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.BlockJUnit4ClassRunner + +@RunWith(BlockJUnit4ClassRunner::class) +class ArgumentHelpersTest { + + @Test + fun transformsArgumentsToEntryList() { + val args = createNativeCallArguments( + arrayListOf("key1", "value1"), + arrayListOf("key2", "value2"), + arrayListOf("key3", "value3") + ) + assertThat(args.toEntryList()).isEqualTo( + listOf( + Entry("key1", "value1"), + Entry("key2", "value2"), + Entry("key3", "value3"), + ) + ) + } + + @Test + fun transfersArgumentsToKeyList() { + val keyList = listOf("key1", "key2", "key3") + val args = createNativeCallArguments("key1", "key2", "key3") + assertThat(args.toKeyList()).isEqualTo(keyList) + } + + @Test + fun throwsIfArgumentsNotValidFormat() { + val invalid = arrayListOf("invalid") + val args = createNativeCallArguments(invalid) + val error = assertThrows(AsyncStorageError::class.java) { + args.toEntryList() + } + + assertThat(error is AsyncStorageError).isTrue() + assertThat(error).hasMessageThat() + .isEqualTo("Invalid key-value format. Expected a list of [key, value] list.") + } + + @Test + fun throwsIfArgumentKeyIsNullOrNotString() { + val argsInvalidNull = createNativeCallArguments(arrayListOf(null, "invalid")) + val errorArgsInvalidNull = assertThrows(AsyncStorageError::class.java) { + argsInvalidNull.toEntryList() + } + assertThat(errorArgsInvalidNull is AsyncStorageError).isTrue() + assertThat(errorArgsInvalidNull).hasMessageThat().isEqualTo("Key cannot be null.") + + val notStringArgs = createNativeCallArguments(arrayListOf(123, "invalid")) + val errorNotString = assertThrows(AsyncStorageError::class.java) { + notStringArgs.toEntryList() + } + assertThat(errorNotString is AsyncStorageError).isTrue() + assertThat(errorNotString).hasMessageThat() + .isEqualTo("Provided key is not string. Only strings are supported as storage key.") + } + + @Test + fun throwsIfArgumentValueNotString() { + val invalidArgs = createNativeCallArguments(arrayListOf("my_key", 666)) + val error = assertThrows(AsyncStorageError::class.java) { + invalidArgs.toEntryList() + } + assertThat(error is AsyncStorageError).isTrue() + assertThat(error).hasMessageThat() + .isEqualTo("Value for key \"my_key\" is not a string. Only strings are supported as a value.") + } +} + + + diff --git a/android/src/test/java/com/reactnativecommunity/asyncstorage/next/StorageTest.kt b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/StorageTest.kt new file mode 100644 index 00000000..fa943d20 --- /dev/null +++ b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/StorageTest.kt @@ -0,0 +1,141 @@ +package com.reactnativecommunity.asyncstorage.next + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class AsyncStorageAccessTest { + private lateinit var asyncStorage: AsyncStorageAccess + private lateinit var database: StorageDb + + @Before + fun setup() { + database = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().context, StorageDb::class.java + ).allowMainThreadQueries().build() + asyncStorage = StorageSupplier(database) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun performsBasicGetSetRemoveOperations() = runBlocking { + val entriesCount = 10 + val entries = createRandomEntries(entriesCount) + val keys = entries.map { it.key } + assertThat(asyncStorage.getValues(keys)).hasSize(0) + asyncStorage.setValues(entries) + assertThat(asyncStorage.getValues(keys)).hasSize(entriesCount) + val indicesToRemove = (1..4).map { Random.nextInt(0, entriesCount) }.distinct() + val toRemove = entries.filterIndexed { index, _ -> indicesToRemove.contains(index) } + asyncStorage.removeValues(toRemove.map { it.key }) + val currentEntries = asyncStorage.getValues(keys) + assertThat(currentEntries).hasSize(entriesCount - toRemove.size) + } + + @Test + fun readsAllKeysAndClearsDb() = runBlocking { + val entries = createRandomEntries(8) + val keys = entries.map { it.key } + asyncStorage.setValues(entries) + val dbKeys = asyncStorage.getKeys() + assertThat(dbKeys).isEqualTo(keys) + asyncStorage.clear() + assertThat(asyncStorage.getValues(keys)).hasSize(0) + } + + @Test + fun mergesDeeplyTwoValues() = runBlocking { + val initialEntry = Entry("key", VALUE_INITIAL) + val overrideEntry = Entry("key", VALUE_OVERRIDES) + asyncStorage.setValues(listOf(initialEntry)) + asyncStorage.mergeValues(listOf(overrideEntry)) + val current = asyncStorage.getValues(listOf("key"))[0] + assertThat(current.value).isEqualTo(VALUE_MERGED) + } + + @Test + fun updatesExistingValues() = runBlocking { + val key = "test_key" + val value = "test_value" + val entries = listOf(Entry(key, value)) + assertThat(asyncStorage.getValues(listOf(key))).hasSize(0) + asyncStorage.setValues(entries) + assertThat(asyncStorage.getValues(listOf(key))).isEqualTo(entries) + val modifiedEntries = listOf(Entry(key, "updatedValue")) + asyncStorage.setValues(modifiedEntries) + assertThat(asyncStorage.getValues(listOf(key))).isEqualTo(modifiedEntries) + } + + + // Test Helpers + private fun createRandomEntries(count: Int = Random.nextInt(10)): List { + val entries = mutableListOf() + for (i in 0 until count) { + entries.add(Entry("key$i", "value$i")) + } + return entries + } + + private val VALUE_INITIAL = JSONObject( + """ + { + "key":"value", + "key2":"override", + "key3":{ + "key4":"value4", + "key6":{ + "key7":"value7", + "key8":"override" + } + } + } +""".trimMargin() + ).toString() + + private val VALUE_OVERRIDES = JSONObject( + """ + { + "key2":"value2", + "key3":{ + "key5":"value5", + "key6":{ + "key8":"value8" + } + } + } +""" + ).toString() + + + private val VALUE_MERGED = JSONObject( + """ + { + "key":"value", + "key2":"value2", + "key3":{ + "key4":"value4", + "key5":"value5", + "key6":{ + "key7":"value7", + "key8":"value8" + } + } + } +""".trimMargin() + ).toString() +} \ No newline at end of file diff --git a/android/src/test/java/com/reactnativecommunity/asyncstorage/next/TestUtils.kt b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/TestUtils.kt new file mode 100644 index 00000000..37ce839c --- /dev/null +++ b/android/src/test/java/com/reactnativecommunity/asyncstorage/next/TestUtils.kt @@ -0,0 +1,84 @@ +package com.reactnativecommunity.asyncstorage.next + +import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.bridge.WritableArray + +fun createNativeCallArguments(vararg list: T): ReadableArray { + val args = ArrayList(list.toList()) + return TestingArguments(args) +} + +internal class TestingArguments(initBackingArray: ArrayList = arrayListOf()) : + WritableArray { + private val backArray: ArrayList = initBackingArray + override fun size() = backArray.size + + override fun toArrayList(): ArrayList = backArray as ArrayList + + override fun isNull(index: Int): Boolean { + TODO("ignored") + } + + override fun getBoolean(index: Int): Boolean { + TODO("ignored") + } + + override fun getDouble(index: Int): Double { + TODO("ignored") + } + + override fun getInt(index: Int): Int { + TODO("ignored") + } + + override fun getString(index: Int): String? { + TODO("ignored") + } + + override fun getArray(index: Int): ReadableArray? { + TODO("ignored") + } + + override fun getMap(index: Int): ReadableMap? { + TODO("ignored") + } + + override fun getDynamic(index: Int): Dynamic { + TODO("ignored") + } + + override fun getType(index: Int): ReadableType { + TODO("ignored") + } + + override fun pushNull() { + TODO("ignored") + } + + override fun pushBoolean(value: Boolean) { + TODO("ignored") + } + + override fun pushDouble(value: Double) { + TODO("ignored") + } + + override fun pushInt(value: Int) { + TODO("ignored") + } + + override fun pushString(value: String?) { + TODO("ignored") + } + + override fun pushArray(array: ReadableArray?) { + TODO("ignore") + } + + override fun pushMap(map: ReadableMap?) { + TODO("ignored") + } +} \ No newline at end of file diff --git a/android/testresults.gradle b/android/testresults.gradle new file mode 100644 index 00000000..50ad3e8d --- /dev/null +++ b/android/testresults.gradle @@ -0,0 +1,38 @@ + +// pretty print test results +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent +tasks.withType(Test) { + testLogging { + + events TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + showExceptions true + showCauses true + showStackTraces true + + debug { + events TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_ERROR, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + } + info.events = debug.events + info.exceptionFormat = debug.exceptionFormat + + afterSuite { desc, result -> + if (!desc.parent) { // will match the outermost suite + def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + def startItem = '| ', endItem = ' |' + def repeatLength = startItem.length() + output.length() + endItem.length() + println('\n' + ('-' * repeatLength) + '\n' + startItem + output + endItem + '\n' + ('-' * repeatLength)) + } + } + } +} \ No newline at end of file diff --git a/example/App.js b/example/App.js index efdd2cc9..b2fe194f 100644 --- a/example/App.js +++ b/example/App.js @@ -21,6 +21,7 @@ import { import GetSetClear from './examples/GetSetClear'; import MergeItem from './examples/MergeItem'; +import BasicExample from './examples/Basic'; const TESTS = { GetSetClear: { @@ -39,6 +40,14 @@ const TESTS = { return ; }, }, + Basic: { + title: 'Basic', + testId: 'basic', + description: 'Basic functionality test', + render() { + return ; + }, + }, }; type Props = {}; @@ -87,6 +96,10 @@ export default class App extends Component { title="Merge Item" onPress={() => this._changeTest('MergeItem')} /> +