diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 83fa51e..2aac369 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -52,11 +52,12 @@ def androidx = [ ] def test = [ + concurrentunit : 'net.jodah:concurrentunit:0.4.6', junit : 'junit:junit:4.13.1', junitX : 'androidx.test.ext:junit:1.0.0', truth : 'com.google.truth:truth:1.1', truthX : 'androidx.test.ext:truth:1.5.0', - robolectric: 'org.robolectric:robolectric:4.4', + robolectric: 'org.robolectric:robolectric:4.5.1', espresso : 'androidx.test.espresso:espresso-core:3.5.1', runner : 'androidx.test:runner:1.5.2', rules : 'androidx.test:rules:1.5.0', diff --git a/simplestore/build.gradle b/simplestore/build.gradle index bdef651..19428fb 100644 --- a/simplestore/build.gradle +++ b/simplestore/build.gradle @@ -23,6 +23,9 @@ android { testOptions { execution 'ANDROIDX_TEST_ORCHESTRATOR' + unitTests { + includeAndroidResources = true + } } defaultConfig { @@ -45,6 +48,7 @@ dependencies { testImplementation deps.test.junitX testImplementation deps.test.truth testImplementation deps.test.truthX + testImplementation deps.test.concurrentunit testImplementation deps.test.robolectric androidTestImplementation deps.test.runner diff --git a/simplestore/src/main/java/com/uber/simplestore/impl/AtomicFile.java b/simplestore/src/main/java/com/uber/simplestore/impl/AtomicFile.java index 2273d7a..4dd4c50 100644 --- a/simplestore/src/main/java/com/uber/simplestore/impl/AtomicFile.java +++ b/simplestore/src/main/java/com/uber/simplestore/impl/AtomicFile.java @@ -21,6 +21,9 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + import org.jetbrains.annotations.Nullable; /* @@ -93,10 +96,7 @@ public FileOutputStream startWrite() throws IOException { try { return new FileOutputStream(mNewName); } catch (FileNotFoundException e) { - File parent = mNewName.getParentFile(); - if (!parent.mkdirs()) { - throw new IOException("Failed to create directory for " + mNewName); - } + resolveParentFolder(e); try { return new FileOutputStream(mNewName); } catch (FileNotFoundException e2) { @@ -105,6 +105,22 @@ public FileOutputStream startWrite() throws IOException { } } + private void resolveParentFolder(FileNotFoundException rawError) throws IOException { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Path parentPath = mNewName.toPath().getParent(); + if (parentPath != null && !Files.exists(parentPath)) { + Files.createDirectories(parentPath); + } else { + throw rawError; + } + } else { + File parent = mNewName.getParentFile(); + if (!parent.mkdirs()) { + throw rawError; + } + } + } + /** * Call when you have successfully finished writing to the stream returned by {@link * #startWrite()}. This will close, sync, and commit the new data. The next attempt to read the diff --git a/simplestore/src/test/java/com/uber/simplestore/impl/AtomicFileConcurrentTest.kt b/simplestore/src/test/java/com/uber/simplestore/impl/AtomicFileConcurrentTest.kt new file mode 100644 index 0000000..42245ff --- /dev/null +++ b/simplestore/src/test/java/com/uber/simplestore/impl/AtomicFileConcurrentTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2019. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.simplestore.impl + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.uber.simplestore.impl.ConcurrentTestUtil.executeConcurrent +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.FileNotFoundException + + +@RunWith(RobolectricTestRunner::class) +class AtomicFileConcurrentTest { + + @Test(expected = FileNotFoundException::class) + fun `case 0 when parent folder is absent will trigger FileNotFoundException`() { + //arrange + //makeParentFolder() + val targetFile = createTargetFile() + //act + val target = targetFile.outputStream() + //assert + assertThat(target).isNotNull() + } + + @Test + fun `case 1 when parent folder is present will not trigger FileNotFoundException`() { + //arrange + makeParentFolder() + val targetFile = createTargetFile() + //act + val target = targetFile.outputStream() + //assert + assertThat(target).isNotNull() + } + + + /** + * This represents a typical file that stores a value for a random key. + */ + private fun createTargetFile(): File { + return File(app().dataDir, "files/simplestore/657b3cd7-f689-451b-aca0-628de60aa234/random_key") + } + + /** + * This presents a typical simple store folder scoped with under an `uuid`. + */ + private fun makeParentFolder() { + val parent = File(app().dataDir, "files/simplestore/657b3cd7-f689-451b-aca0-628de60aa234") + parent.mkdirs() + } + + private fun app(): Application { + return ApplicationProvider.getApplicationContext() + } +} diff --git a/simplestore/src/test/java/com/uber/simplestore/impl/ConcurrentTestUtil.kt b/simplestore/src/test/java/com/uber/simplestore/impl/ConcurrentTestUtil.kt new file mode 100644 index 0000000..cbdbdc4 --- /dev/null +++ b/simplestore/src/test/java/com/uber/simplestore/impl/ConcurrentTestUtil.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2019. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.simplestore.impl + +import net.jodah.concurrentunit.Waiter +import java.util.concurrent.Executors + + +/** + * A utility class to execute a given action concurrently. + * It will trigger [Waiter.fail] if the action throws an exception during the whole concurrent execution process. + * It will trigger [Waiter.resume] if the action is concluded successfully for the whole concurrent execution. + */ +object ConcurrentTestUtil { + /** + * The maximum number of threads to be executed concurrently. + */ + private const val MAX_THREAD = 5 + + + /** + * Executes the given action concurrently. + * It will trigger [Waiter.fail] if the action throws an exception during the whole concurrent execution process. + * It will trigger [Waiter.resume] if the action is concluded successfully for the whole concurrent execution. + */ + fun executeConcurrent(action: () -> Unit) { + val waiter = Waiter() + for (i in 0 until 1000) { + val service = Executors.newFixedThreadPool(MAX_THREAD) + for (j in 0 until 10) { + service.submit { + executeConcurrentInternally(action, waiter) + } + } + } + waiter.await(1000L * MAX_THREAD) + } + + private fun executeConcurrentInternally(action: () -> Unit, waiter: Waiter) { + try { + run(action) + } catch (e: Exception) { + waiter.fail(e) + } finally { + waiter.resume() + } + } + + +}