diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f8fbda7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 + +[*.{kt,kts}] +ij_continuation_indent_size = 2 + +[*.{java,xml}] +ij_continuation_indent_size = 4 diff --git a/gradle.properties b/gradle.properties index 0a54152..8cdeb9d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,7 +25,7 @@ android.enableJetifier=false android.injected.androidTest.leaveApksInstalledAfterRun=true GROUP=com.wealthfront -VERSION_NAME=2.1.3-SNAPSHOT +VERSION_NAME=2.1.3 POM_DESCRIPTION=Simple Android library to capture screenshots deterministically diff --git a/screencaptor-sample/src/test/java/com/wealthfront/screencaptor/sample/ScreenshotTest.kt b/screencaptor-sample/src/test/java/com/wealthfront/screencaptor/sample/ScreenshotTest.kt index a6ebe31..d686fc6 100644 --- a/screencaptor-sample/src/test/java/com/wealthfront/screencaptor/sample/ScreenshotTest.kt +++ b/screencaptor-sample/src/test/java/com/wealthfront/screencaptor/sample/ScreenshotTest.kt @@ -19,7 +19,6 @@ import org.junit.After import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.GraphicsMode import java.io.File diff --git a/screencaptor/src/main/java/com/wealthfront/screencaptor/ScreenCaptor.kt b/screencaptor/src/main/java/com/wealthfront/screencaptor/ScreenCaptor.kt index 9c8fe48..7cf0a8e 100644 --- a/screencaptor/src/main/java/com/wealthfront/screencaptor/ScreenCaptor.kt +++ b/screencaptor/src/main/java/com/wealthfront/screencaptor/ScreenCaptor.kt @@ -21,13 +21,14 @@ import com.wealthfront.screencaptor.globalmutator.CursorHider import com.wealthfront.screencaptor.globalmutator.ScrollbarHider import com.wealthfront.screencaptor.globalmutator.ViewTreeMutator import com.wealthfront.screencaptor.idlingresource.ScreenshotIdlingResource -import eu.bolt.screenshotty.Screenshot import eu.bolt.screenshotty.ScreenshotActionOrder import eu.bolt.screenshotty.ScreenshotManagerBuilder import eu.bolt.screenshotty.util.ScreenshotFileSaver import java.io.File import java.io.FileOutputStream import java.util.Locale.ENGLISH +import kotlin.io.path.Path +import kotlin.io.path.createDirectories /** * Has the ability to take a screenshot of the current view displayed on the screen using the method [takeScreenshot]. @@ -45,15 +46,14 @@ object ScreenCaptor { screenshotNameSuffix: String = "", screenshotFormat: ScreenshotFormat = PNG, ): File { - if (!File(screenshotDirectory).exists()) { + if (!File(screenshotDirectory).isDirectory) { Log.d(SCREENSHOT, "Creating directory $screenshotDirectory since it does not exist") - val screenshotDirsCreated = File(screenshotDirectory).mkdirs() - assert(screenshotDirsCreated) } + ensureScreenshotDirectoryExists(screenshotDirectory) val deviceName = MANUFACTURER.replaceWithUnderscore() + "_" + MODEL.replaceWithUnderscore() val screenshotId = - "${screenshotName.toLowerCase(ENGLISH)}_${deviceName}_${SDK_INT}_$screenshotNameSuffix" + "${screenshotName.lowercase(ENGLISH)}_${deviceName}_${SDK_INT}_$screenshotNameSuffix" return File("$screenshotDirectory/$screenshotId.${screenshotFormat.extension}") } @@ -61,7 +61,7 @@ object ScreenCaptor { activity: Activity, screenshotFile: File, screenshotQuality: ScreenshotQuality = BEST, - onSuccess: (Screenshot) -> Unit + onComplete: () -> Unit ) { val screenshotManager = ScreenshotManagerBuilder(activity) .withCustomActionOrder(ScreenshotActionOrder.fallbacksFirst()) @@ -69,14 +69,27 @@ object ScreenCaptor { screenshotManager.makeScreenshot() .observe({ screenshot -> - val fileSaver = ScreenshotFileSaver.create( - compressFormat = Bitmap.CompressFormat.PNG, - compressQuality = screenshotQuality.value - ) - fileSaver.saveToFile(screenshotFile, screenshot) + try { + val fileSaver = ScreenshotFileSaver.create( + compressFormat = Bitmap.CompressFormat.PNG, + compressQuality = screenshotQuality.value + ) + fileSaver.saveToFile(screenshotFile, screenshot) + } finally { + onComplete() + } + }, { throwable -> + try { + onComplete() + } finally { + throw throwable + } + }) + } - onSuccess.invoke(screenshot) - }, { throwable -> throw throwable }) + private fun releaseScreenshotIdlingResource(idlingResource: ScreenshotIdlingResource) { + idlingResource.setScreenshotCaptured() + IdlingRegistry.getInstance().unregister(idlingResource) } private fun captureScreenshot( @@ -156,33 +169,37 @@ object ScreenCaptor { val idlingResource = ScreenshotIdlingResource() IdlingRegistry.getInstance().register(idlingResource) - val screenshotFile = getScreenshotFile( - screenshotDirectory = screenshotDirectory, - screenshotName = screenshotName, - screenshotNameSuffix = screenshotNameSuffix, - screenshotFormat = screenshotFormat - ) - activityScenario.onActivity { activity -> - ViewTreeMutator.Builder() - .addMutations(defaultGlobalMutations) - .addMutations(globalViewMutations) - .build() - .mutate(activity) + try { + val screenshotFile = getScreenshotFile( + screenshotDirectory = screenshotDirectory, + screenshotName = screenshotName, + screenshotNameSuffix = screenshotNameSuffix, + screenshotFormat = screenshotFormat + ) + activityScenario.onActivity { activity -> + ViewTreeMutator.Builder() + .addMutations(defaultGlobalMutations) + .addMutations(globalViewMutations) + .build() + .mutate(activity) - captureScreenshot( - activity, - screenshotFile, - screenshotQuality - ) { screenshot -> - idlingResource.setScreenshotCaptured() - IdlingRegistry.getInstance().unregister(idlingResource) + captureScreenshot( + activity, + screenshotFile, + screenshotQuality + ) { + releaseScreenshotIdlingResource(idlingResource) + } } - } - viewMutations.forEach { viewMutation -> - with(viewMutation) { - getViewInteraction().perform(getRestoreAction()) + viewMutations.forEach { viewMutation -> + with(viewMutation) { + getViewInteraction().perform(getRestoreAction()) + } } + } catch (error: Throwable) { + releaseScreenshotIdlingResource(idlingResource) + throw error } } @@ -265,6 +282,31 @@ object ScreenCaptor { } } +internal fun ensureScreenshotDirectoryExists( + screenshotDirectory: String, + createDirectories: (java.nio.file.Path) -> Unit = { it.createDirectories() } +) { + val directory = File(screenshotDirectory) + if (directory.isDirectory) { + return + } + + try { + createDirectories(Path(screenshotDirectory)) + } catch (e: Exception) { + throw IllegalStateException( + "Failed to create screenshot directory for path: $screenshotDirectory", + e + ) + } + + if (!directory.isDirectory) { + throw IllegalStateException( + "Failed to create screenshot directory for path: $screenshotDirectory" + ) + } +} + private fun String.replaceWithUnderscore(): String { return lowercase(ENGLISH).replace(" ", "_") } diff --git a/screencaptor/src/main/java/com/wealthfront/screencaptor/idlingresource/ScreenshotIdlingResource.kt b/screencaptor/src/main/java/com/wealthfront/screencaptor/idlingresource/ScreenshotIdlingResource.kt index 074375d..a7db296 100644 --- a/screencaptor/src/main/java/com/wealthfront/screencaptor/idlingresource/ScreenshotIdlingResource.kt +++ b/screencaptor/src/main/java/com/wealthfront/screencaptor/idlingresource/ScreenshotIdlingResource.kt @@ -4,6 +4,7 @@ import androidx.test.espresso.IdlingResource internal class ScreenshotIdlingResource: IdlingResource { private var resourceCallback: IdlingResource.ResourceCallback? = null + @Volatile private var screenshotCaptured = false override fun getName(): String = "ScreenCaptor#captureScreenshot" diff --git a/screencaptor/src/test/AndroidManifest.xml b/screencaptor/src/test/AndroidManifest.xml new file mode 100644 index 0000000..c21c8d4 --- /dev/null +++ b/screencaptor/src/test/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenCaptorTest.kt b/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenCaptorTest.kt new file mode 100644 index 0000000..b51deaa --- /dev/null +++ b/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenCaptorTest.kt @@ -0,0 +1,49 @@ +package com.wealthfront.screencaptor + +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.IdlingRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class ScreenCaptorTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @After + fun unregisterIdlingResources() { + IdlingRegistry.getInstance().resources.toList().forEach { resource -> + IdlingRegistry.getInstance().unregister(resource) + } + } + + @Test + fun takeScreenshot_releasesIdlingResourceAndRethrows_whenDirectoryCreationFails() { + val blockingFile = temporaryFolder.newFile("not-a-directory") + val screenshotDirectory = File(blockingFile, "screenshots").absolutePath + val scenario = ActivityScenario.launch(AppCompatActivity::class.java) + scenario.use { scenario -> + val exception = assertThrows(IllegalStateException::class.java) { + ScreenCaptor.takeScreenshot( + activityScenario = scenario, + screenshotName = "test", + screenshotDirectory = screenshotDirectory, + ) + } + + assertThat(exception).hasMessageThat().isEqualTo( + "Failed to create screenshot directory for path: $screenshotDirectory" + ) + assertThat(IdlingRegistry.getInstance().resources).isEmpty() + } + } +} diff --git a/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenshotDirectoryTest.kt b/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenshotDirectoryTest.kt new file mode 100644 index 0000000..e849bff --- /dev/null +++ b/screencaptor/src/test/java/com/wealthfront/screencaptor/ScreenshotDirectoryTest.kt @@ -0,0 +1,62 @@ +package com.wealthfront.screencaptor + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +class ScreenshotDirectoryTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun ensureScreenshotDirectoryExists_createsMissingDirectory() { + val screenshotDirectory = File(temporaryFolder.root, "screenshots").absolutePath + + ensureScreenshotDirectoryExists(screenshotDirectory) + + assertThat(File(screenshotDirectory).isDirectory).isTrue() + } + + @Test + fun ensureScreenshotDirectoryExists_doesNothingWhenDirectoryAlreadyExists() { + val screenshotDirectory = temporaryFolder.newFolder("screenshots").absolutePath + + ensureScreenshotDirectoryExists(screenshotDirectory) + + assertThat(File(screenshotDirectory).isDirectory).isTrue() + } + + @Test + fun ensureScreenshotDirectoryExists_throwsIllegalStateException_whenDirectoryCannotBeCreated() { + val blockingFile = temporaryFolder.newFile("not-a-directory") + val screenshotDirectory = File(blockingFile, "screenshots").absolutePath + + val exception = assertThrows(IllegalStateException::class.java) { + ensureScreenshotDirectoryExists(screenshotDirectory) + } + + assertThat(exception).hasMessageThat().isEqualTo( + "Failed to create screenshot directory for path: $screenshotDirectory" + ) + } + + @Test + fun ensureScreenshotDirectoryExists_wrapsCause_whenCreationThrows() { + val screenshotDirectory = File(temporaryFolder.root, "screenshots").absolutePath + val cause = IOException("disk full") + + val exception = assertThrows(IllegalStateException::class.java) { + ensureScreenshotDirectoryExists(screenshotDirectory) { throw cause } + } + + assertThat(exception).hasMessageThat().isEqualTo( + "Failed to create screenshot directory for path: $screenshotDirectory" + ) + assertThat(exception.cause).isSameInstanceAs(cause) + } +}