Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .editorconfig

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This more closely aligns with how we configure formatting rules in wf-android
https://www.jetbrains.com/help/idea/editorconfig.html
Without this my IDE was formatting with 4 spaces, which didn't align with the rest of the codebase

Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -45,38 +46,50 @@ 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}")
}

private fun captureScreenshot(
activity: Activity,
screenshotFile: File,
screenshotQuality: ScreenshotQuality = BEST,
onSuccess: (Screenshot) -> Unit
onComplete: () -> Unit
) {
val screenshotManager = ScreenshotManagerBuilder(activity)
.withCustomActionOrder(ScreenshotActionOrder.fallbacksFirst())
.build()

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(
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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) {

@cmathew cmathew Sep 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which exceptions might the try block throw? Should we just let it bubble up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually a java.nio.file.FileSystemException. Reason I'm catching it is just to wrap the exception to provide a more context-specific error message (and include the directory path we tried)

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(" ", "_")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions screencaptor/src/test/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:theme="@style/Theme.AppCompat">
<activity android:name="androidx.appcompat.app.AppCompatActivity" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading