On device testing

test: only ever delete MediaStore rows the test itself inserted
test: cover the pre-Q save path on API 26
test: cover #4009 end-to-end against a real MediaStore on API 29
This commit is contained in:
davotoula
2026-08-31 12:38:49 +02:00
parent cbd0a4a174
commit af49bcc396
2 changed files with 310 additions and 0 deletions
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
* writes straight to a public directory and lets the media scanner index it.
*
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
* as the MediaStore path. minSdk is 26, so this range ships.
*
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskLegacyStorageTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val watchedDirs = listOf("Movies", "Pictures", "Download", "Music")
private val createdFiles = mutableListOf<File>()
@Before
fun onlyBelowScopedStorage() {
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
// The legacy writer needs the runtime permission; no androidx.test:rules on the
// classpath, so grant it through the instrumentation shell instead. The output has
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
// early kills the command before it applies.
val fd =
InstrumentationRegistry
.getInstrumentation()
.uiAutomation
.executeShellCommand(
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
)
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
assertEquals(
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
PackageManager.PERMISSION_GRANTED,
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
)
// Holding the permission is not enough below Q: external storage is mounted into
// the process when it forks, so a grant to an already-running process never
// reaches it and every write fails with EACCES. Probe for real writability and
// skip rather than report a routing failure that is really a harness problem.
assumeTrue(
"External storage is not writable by this process. Below API 29 the grant must " +
"exist before the process starts - install with `adb install -r -g` and drive " +
"the run with `am instrument`; Gradle's connectedAndroidTest cannot grant in time.",
canWriteToPublicStorage(),
)
}
private fun canWriteToPublicStorage(): Boolean =
try {
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
val probe = File(dir, ".write-probe-${System.nanoTime()}")
probe.createNewFile().also { probe.delete() }
} catch (e: IOException) {
false
}
@After
fun cleanUp() {
createdFiles.forEach { it.delete() }
}
@Test
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
@Test
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
@Test
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
@Test
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
/**
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
* Checking the other directories is the point: the bug was everything landing in Pictures.
*/
private fun assertRoutes(
mimeType: String,
expectedDir: String,
) {
val before = snapshot()
val localFile = File(context.cacheDir, "legacy-save-${System.nanoTime()}.bin")
localFile.writeBytes(ByteArray(2048) { it.toByte() })
var failure: Throwable? = null
var succeeded = false
runBlocking {
MediaSaverToDisk.save(
localFile = localFile,
mimeType = mimeType,
context = context,
onSuccess = { succeeded = true },
onError = { failure = it },
)
}
localFile.delete()
assertNull("save() reported an error: ${failure?.message}", failure)
assertTrue("save() never reported success", succeeded)
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
}
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
* End-to-end regression test for issue #4009.
*
* MediaProvider validates RELATIVE_PATH's primary directory against the collection
* being written to. Filing a video under "Pictures" threw
* `IllegalArgumentException: Primary directory Pictures not allowed for
* content://media/external/video/media; allowed directories are [DCIM, Movies]`
* on Android 10; later releases accept the mismatch and silently misfile the video.
*
* This drives the real ContentResolver, so it catches both symptoms: the insert has
* to succeed AND the row has to land in the directory the collection accepts.
*/
@RunWith(AndroidJUnit4::class)
class MediaSaverToDiskMediaStoreTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver: ContentResolver get() = context.contentResolver
/** Only rows this test inserted, identified by id in the collection they went into. */
private val created = mutableListOf<Pair<Uri, Long>>()
@Before
fun requiresScopedStorage() {
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
}
@After
fun cleanUp() {
created.forEach { (collection, id) ->
resolver.delete(collection, "${MediaStore.MediaColumns._ID} = ?", arrayOf(id.toString()))
}
}
@Test
fun savingAVideoLandsInMoviesAndNotPictures() {
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
assertEquals("Movies/Amethyst/", relativePath)
}
@Test
fun savingAnImageStillLandsInPictures() {
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
assertEquals("Pictures/Amethyst/", relativePath)
}
private fun saveAndReadBackRelativePath(
mimeType: String,
collection: Uri,
): String? {
// Anything at or below this id predates the test and must never be read or deleted:
// this suite is meant to be runnable on a real device holding real media.
val highWaterMark = maxIdIn(collection)
val localFile = File(context.cacheDir, "media-saver-${System.nanoTime()}.bin")
localFile.writeBytes(ByteArray(2048) { it.toByte() })
var failure: Throwable? = null
var succeeded = false
runBlocking {
MediaSaverToDisk.save(
localFile = localFile,
mimeType = mimeType,
context = context,
onSuccess = { succeeded = true },
onError = { failure = it },
)
}
localFile.delete()
// Surfaces the #4009 IllegalArgumentException as the test failure message.
assertNull("save() reported an error: ${failure?.message}", failure)
assertTrue("save() never reported success", succeeded)
return rowInsertedAfter(collection, highWaterMark)
}
private fun maxIdIn(collection: Uri): Long {
resolver
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
?.use { cursor ->
if (cursor.moveToFirst()) return cursor.getLong(0)
}
return -1L
}
/** Reads back the row the save just inserted and records it for cleanup. */
private fun rowInsertedAfter(
collection: Uri,
highWaterMark: Long,
): String? {
resolver
.query(
collection,
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
"${MediaStore.MediaColumns._ID} > ?",
arrayOf(highWaterMark.toString()),
"${MediaStore.MediaColumns._ID} ASC",
)?.use { cursor ->
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
created.add(collection to cursor.getLong(0))
return cursor.getString(1)
}
return null
}
}