3

Since the improved privacy changes on Android 10 Android 10 Privacy changes, I've noticed that my screenshot failure test watcher rule in Kotlin, that extends the Espresso BasicScreenCaptureProcessor no longer saves failure screenshots because I am using the deprecated getExternalStoragePublicDirectory on Android 10.

The concept currently implement is very similar to How to take screenshot at the point where test fail in Espresso?

class TestScreenCaptureProcessor : BasicScreenCaptureProcessor() {
    init {
        this.mDefaultScreenshotPath = File(
            File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
                "Failure_Screenshots"
            ).absolutePath
        )
    }

As seen in other posts, I could use the getInstrumentation().getTargetContext().getApplicationContext().getExternalFilesDir(DIRECTORY_PICTURES)

that would store the file in - /sdcard/Android/data/your.package.name/files/Pictures directory, but the connectedAndroidTest gradle task deletes the app at the end along with it the folders listed above.

I wondered if anyone else had come across something similar and has considered a way of storing failure screenshots on Android 10, in a location that will not be deleted when the tests have finished running & somewhere that Espresso Instrumentation tests can access.

My UI tests are run on a variety of devices, so need a generic way of storing the files is required to suite all models.

lovelylauz219
  • 101
  • 1
  • 6

1 Answers1

7

After a lot of research I found a way to save screenshots in kotlin based on the SDK version using MediaStore.


/**
 * storeFailureScreenshot will store the bitmap based on the SDK level of the 
 * device. Due to security improvements and changes to how data can be accessed in 
 * SDK levels >=29 Failure screenshots will be stored in 
 * sdcard/DIRECTORY_PICTURES/Failure_Screenshots.
 */
fun storeFailureScreenshot(bitmap: Bitmap, screenshotName: String) {
    val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext.contentResolver

    // Check SDK version of device to determine how to save the screenshot.
    if (android.os.Build.VERSION.SDK_INT >= 29) {
        useMediaStoreScreenshotStorage(
            contentValues,
            contentResolver,
            screenshotName,
            SCREENSHOT_FOLDER_LOCATION,
            bitmap
        )
    } else {
        usePublicExternalScreenshotStorage(
            contentValues,
            contentResolver,
            screenshotName,
            SCREENSHOT_FOLDER_LOCATION,
            bitmap
        )
    }
}

/**
 * This will be used by devices with SDK versions >=29. This is to overcome scoped 
 * storage considerations now in the SDK version listed to help limit file 
 * clutter. A Uniform resource identifier (Uri) is used to insert bitmap into
 * the gallery using the contentValues previously specified. The contentResolver 
 * provides application access to content model to access and publish data in a 
 * secure manner, using MediaStore collections to do so. Files will
 * be stored in sdcard/Pictures
 */
private fun useMediaStoreScreenshotStorage(
    contentValues: ContentValues,
    contentResolver: ContentResolver,
    screenshotName: String,
    screenshotLocation: String,
    bitmap: Bitmap
) {
    contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "$screenshotName.jpeg")
    contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + screenshotLocation)

    val uri: Uri? = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
    if (uri != null) {
        contentResolver.openOutputStream(uri)?.let { saveScreenshotToStream(bitmap, it) }
        contentResolver.update(uri, contentValues, null, null)
    }
}

/**
 * Method to access internal storage on a handset with SDK version below 29. 
 * Directory will be in sdcard/Pictures. Relevant sub directories will be created 
 * & screenshot will be stored as a .jpeg file.
 */
private fun usePublicExternalScreenshotStorage(
    contentValues: ContentValues,
    contentResolver: ContentResolver,
    screenshotName: String,
    screenshotLocation: String,
    bitmap: Bitmap
) {
    val directory = File(
        Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_PICTURES + screenshotLocation).toString())

    if (!directory.exists()) {
        directory.mkdirs()
    }

    val file = File(directory, "$screenshotName.jpeg")
    saveScreenshotToStream(bitmap, FileOutputStream(file))

    val values = contentValues
    contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
}

/**
 * Assigns the assignments about the Image media including, image type & date 
 * taken. Content values are used so the contentResolver can interpret them. These 
 * are applied to the contentValues object.
 */
val contentValues = ContentValues().apply {
    put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
    put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}

/**
 * Compresses the bitmap object to a .jpeg image format using the specified
 * OutputStream of bytes.
 */
private fun saveScreenshotToStream(bitmap: Bitmap, outputStream: OutputStream) {
    outputStream.use {
        try {
            bitmap.compress(Bitmap.CompressFormat.JPEG, 50, it)
        } catch (e: IOException) {
            Timber.e("Screenshot was not stored at this time")
        }
    }
}

Used in conjunction with a TestWatcher that takes a screenshot on a UI test failure. This is then added to the test class as a rule.

private val deviceLanguage = Locale.getDefault().language

/**
 * Finds current date and time & is put into format of Wed-Mar-06-15:52:17.
 */
fun getDate(): String = SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())

/**
 * ScreenshotFailureRule overrides TestWatcher failed rule and instead takes a 
 * screenshot using the UI Automation takeScreenshot method and the 
 * storeFailureScreenshot to decide where to store the bitmap when a failure 
 * occurs.
 */
class ScreenshotFailureRule : TestWatcher() {
    override fun failed(e: Throwable?, description: Description) {
        val screenShotName = "$deviceLanguage-${description.methodName}-${getDate()}"
        val bitmap = getInstrumentation().uiAutomation.takeScreenshot()
        storeFailureScreenshot(bitmap, screenShotName)
    }
}

file is stored in sdcard/Pictures/Failure_Screenshots with a name of en-testMethodName-Day-Month-Date-HH_MM_SS

Rule called using:

val screenshotFailureRule = ScreenshotFailureRule()
lovelylauz219
  • 101
  • 1
  • 6
  • This is awesome and thanks for sharing it. This probably works fantastic on a CI but on my local, any images created during run #1 cannot be overwritten on run #2 because the app no longer owns those files. – Afzal N Feb 10 '21 at 04:22