mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+163
@@ -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() }
|
||||
}
|
||||
+147
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user