fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
+52 -49
View File
@@ -2,78 +2,81 @@ group = "com.fluttercavalry.saf_util"
version = "1.0-SNAPSHOT"
buildscript {
val kotlinVersion = "2.3.20"
repositories {
google()
mavenCentral()
}
val kotlinVersion = "2.3.20"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:9.0.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
dependencies {
classpath("com.android.tools.build:gradle:9.0.1")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
id("com.android.library")
}
android {
namespace = "com.fluttercavalry.saf_util"
namespace = "com.fluttercavalry.saf_util"
compileSdk = 36
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
getByName("test") {
java.srcDirs("src/test/kotlin")
}
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
getByName("test") {
java.srcDirs("src/test/kotlin")
}
}
defaultConfig {
minSdk = 24
}
defaultConfig {
minSdk = 24
}
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
all {
it.useJUnitPlatform()
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
all {
it.useJUnitPlatform()
it.outputs.upToDateWhen { false }
it.outputs.upToDateWhen { false }
it.testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
showStandardStreams = true
}
}
it.testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
showStandardStreams = true
}
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
implementation("androidx.documentfile:documentfile:1.1.0")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
implementation("androidx.documentfile:documentfile:1.1.0")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.15.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
}
@@ -0,0 +1,59 @@
package com.fluttercavalry.saf_util
import android.os.ParcelFileDescriptor
/** Owns descriptors for exactly one attached Flutter engine at a time. */
internal class FileDescriptorRegistry {
private val lock = Any()
private val descriptors = mutableMapOf<Int, ParcelFileDescriptor>()
private var attached = false
fun attach() {
synchronized(lock) {
attached = true
}
}
/** Returns the borrowed descriptor number, or null after closing a late descriptor. */
fun register(descriptor: ParcelFileDescriptor): Int? {
val fd = descriptor.fd
synchronized(lock) {
if (attached) {
descriptors[fd] = descriptor
return fd
}
}
closeBestEffort(descriptor)
return null
}
/** Removes ownership before closing, making repeated and unknown closes idempotent. */
fun close(fd: Int) {
val descriptor = synchronized(lock) { descriptors.remove(fd) }
descriptor?.close()
}
/** Rejects future registrations, then drains all current ownership best effort. */
fun detach() {
val owned =
synchronized(lock) {
attached = false
val snapshot = descriptors.values.toList()
descriptors.clear()
snapshot
}
owned.forEach(::closeBestEffort)
}
internal val trackedCount: Int
get() = synchronized(lock) { descriptors.size }
private fun closeBestEffort(descriptor: ParcelFileDescriptor) {
try {
descriptor.close()
} catch (_: Exception) {
// Continue draining the remaining plugin-owned descriptors.
}
}
}
@@ -0,0 +1,79 @@
package com.fluttercavalry.saf_util
import android.content.ContentResolver
import android.content.Intent
import android.content.UriPermission
import android.net.Uri
import android.provider.DocumentsContract
/** Resolves document-tree URIs to the exact URI held by Android's permission store. */
internal object PersistedPermissionResolver {
fun resolve(
contentResolver: ContentResolver,
requestedUri: Uri
): UriPermission? {
val permissions = contentResolver.persistedUriPermissions
permissions.firstOrNull { it.uri == requestedUri }?.let { return it }
val requestedIdentity = treeIdentity(requestedUri) ?: return null
return permissions.firstOrNull { permission ->
treeIdentity(permission.uri) == requestedIdentity
}
}
fun hasPermission(
contentResolver: ContentResolver,
requestedUri: Uri,
checkRead: Boolean,
checkWrite: Boolean
): Boolean {
val permission = resolve(contentResolver, requestedUri) ?: return false
return (!checkRead || permission.isReadPermission) &&
(!checkWrite || permission.isWritePermission)
}
fun release(
contentResolver: ContentResolver,
requestedUri: Uri,
read: Boolean,
write: Boolean
) {
val permission = resolve(contentResolver, requestedUri) ?: return
var flags = 0
if (read && permission.isReadPermission) {
flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
if (write && permission.isWritePermission) {
flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
if (flags == 0) return
contentResolver.releasePersistableUriPermission(permission.uri, flags)
}
fun resolveUri(
contentResolver: ContentResolver,
requestedUri: Uri
): Uri? = resolve(contentResolver, requestedUri)?.uri
fun getPersistedUris(contentResolver: ContentResolver): List<Uri> = contentResolver.persistedUriPermissions.map { it.uri }
private fun treeIdentity(uri: Uri): TreeIdentity? {
try {
if (!DocumentsContract.isTreeUri(uri)) return null
val scheme = uri.scheme ?: return null
val authority = uri.authority ?: return null
val rootDocumentId = DocumentsContract.getTreeDocumentId(uri)
return TreeIdentity(scheme, authority, rootDocumentId)
} catch (_: IllegalArgumentException) {
return null
}
}
private data class TreeIdentity(
val scheme: String,
val authority: String,
val rootDocumentId: String
)
}
@@ -6,11 +6,8 @@ import android.content.Intent
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.Point
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.core.net.toUri
@@ -51,7 +48,7 @@ class SafUtilPlugin :
private val activityResultListener = PluginRegistry.ActivityResultListener { requestCode, resultCode, data ->
onActivityResult(requestCode, resultCode, data)
}
private val fdMap = mutableMapOf<Int, ParcelFileDescriptor>()
private val fileDescriptorRegistry = FileDescriptorRegistry()
/** Takes ownership before replying so a Result can never be answered twice. */
private fun takePendingResult(): Result? {
@@ -62,9 +59,10 @@ class SafUtilPlugin :
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
fileDescriptorRegistry.attach()
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "saf_util")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.applicationContext
}
override fun onDetachedFromActivity() {
@@ -279,10 +277,11 @@ class SafUtilPlugin :
val uri = call.argument<String>("uri") as String
val df = documentFileFromUri(uri, false) ?: throw Exception("Failed to get DocumentFile from $uri")
val fd =
val descriptor =
context.contentResolver.openFileDescriptor(df.uri, "r") ?: throw Exception("Failed to open file descriptor")
val fdInt = fd.fd
fdMap[fdInt] = fd
val fdInt =
fileDescriptorRegistry.register(descriptor)
?: throw IllegalStateException("Plugin detached before file descriptor opened")
launch(Dispatchers.Main) {
result.success(fdInt)
@@ -299,8 +298,7 @@ class SafUtilPlugin :
CoroutineScope(Dispatchers.IO).launch {
try {
val fdInt = call.argument<Int>("fd") as Int
val fd = fdMap.remove(fdInt)
fd?.close()
fileDescriptorRegistry.close(fdInt)
launch(Dispatchers.Main) {
result.success(null)
@@ -634,8 +632,8 @@ class SafUtilPlugin :
val checkWrite = call.argument<Boolean>("checkWrite") ?: false
val persisted =
hasPersistedUriPermission(
context,
PersistedPermissionResolver.hasPermission(
context.contentResolver,
uri.toUri(),
checkRead,
checkWrite
@@ -651,6 +649,43 @@ class SafUtilPlugin :
}
}
"resolvePersistedPermissionUri" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val uri = call.argument<String>("uri") as String
val persistedUri =
PersistedPermissionResolver.resolveUri(
context.contentResolver,
uri.toUri()
)
launch(Dispatchers.Main) {
result.success(persistedUri?.toString())
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"getPersistedPermissionUris" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
val persistedUris =
PersistedPermissionResolver.getPersistedUris(context.contentResolver)
.map(Uri::toString)
launch(Dispatchers.Main) {
result.success(persistedUris)
}
} catch (err: Exception) {
launch(Dispatchers.Main) {
result.error("PluginError", err.message, null)
}
}
}
}
"releasePersistedPermission" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -658,17 +693,11 @@ class SafUtilPlugin :
val read = call.argument<Boolean>("read") ?: true
val write = call.argument<Boolean>("write") ?: false
context.contentResolver.releasePersistableUriPermission(
PersistedPermissionResolver.release(
context.contentResolver,
uri.toUri(),
if (read && write) {
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
} else if (read) {
Intent.FLAG_GRANT_READ_URI_PERMISSION
} else if (write) {
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
} else {
0
}
read,
write
)
launch(Dispatchers.Main) {
result.success(null)
@@ -702,27 +731,18 @@ class SafUtilPlugin :
return@launch
}
val bitmap: Bitmap?
// Use MediaMetadataRetriever for video files.
if (mime.startsWith("video/")) {
val mmr = MediaMetadataRetriever()
mmr.setDataSource(context, uri)
bitmap =
if (Build.VERSION.SDK_INT >= 27) {
mmr.getScaledFrameAtTime(-1, OPTION_CLOSEST_SYNC, width, height)
} else {
mmr.frameAtTime
}
} else {
// Use DocumentsContract for other files.
bitmap =
val bitmap: Bitmap? =
if (mime.startsWith("video/")) {
extractVideoFrame(context, uri, width, height)
} else {
// Use DocumentsContract for other files.
DocumentsContract.getDocumentThumbnail(
context.contentResolver,
uri,
Point(width, height),
null
)
}
}
if (bitmap != null) {
File(dest).writeBitmap(
@@ -825,6 +845,7 @@ class SafUtilPlugin :
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
fileDescriptorRegistry.detach()
}
private fun documentFileFromUri(
@@ -850,32 +871,6 @@ class SafUtilPlugin :
return res
}
private fun hasPersistedUriPermission(
context: Context,
uri: Uri,
checkRead: Boolean,
checkWrite: Boolean
): Boolean {
val permissions = context.contentResolver.persistedUriPermissions
for (permission in permissions) {
if (areSameDocumentLocation(permission.uri, uri)) {
val hasRead = !checkRead || permission.isReadPermission
val hasWrite = !checkWrite || permission.isWritePermission
return hasRead && hasWrite
}
}
return false
}
private fun areSameDocumentLocation(
treeUri: Uri,
docUri: Uri
): Boolean {
val treeDocId = DocumentsContract.getTreeDocumentId(treeUri)
val docId = DocumentsContract.getDocumentId(docUri)
return treeDocId == docId
}
private fun findDirectChild(
parentUri: Uri,
name: String
@@ -0,0 +1,25 @@
package com.fluttercavalry.saf_util
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC
import android.net.Uri
import android.os.Build
internal fun extractVideoFrame(
context: Context,
uri: Uri,
width: Int,
height: Int,
retriever: MediaMetadataRetriever = MediaMetadataRetriever()
): Bitmap? = try {
retriever.setDataSource(context, uri)
if (Build.VERSION.SDK_INT >= 27) {
retriever.getScaledFrameAtTime(-1, OPTION_CLOSEST_SYNC, width, height)
} else {
retriever.frameAtTime
}
} finally {
retriever.release()
}
@@ -0,0 +1,266 @@
package com.fluttercavalry.saf_util
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.UriPermission
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
internal class SafUtilPersistedPermissionTest {
private val context: Context = RuntimeEnvironment.getApplication()
@Test
fun pickerReturnedRootAndDescendantResolveToPersistedTreeUri() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val pickerReturnedUri = DocumentFile.fromTreeUri(context, treeUri)!!.uri
val descendantUri =
DocumentsContract.buildDocumentUriUsingTree(treeUri, "$ROOT_ID/Season 1/video.mkv")
val permission = permission(treeUri, read = true, write = true)
val resolver = resolverWith(permission)
assertNotEquals(treeUri, pickerReturnedUri)
assertSame(permission, PersistedPermissionResolver.resolve(resolver, pickerReturnedUri))
assertSame(permission, PersistedPermissionResolver.resolve(resolver, descendantUri))
PersistedPermissionResolver.release(
resolver,
pickerReturnedUri,
read = true,
write = true
)
verify(resolver).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
@Test
fun providerAuthorityIsPartOfTreeIdentity() {
val treeA = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val treeB = DocumentsContract.buildTreeDocumentUri(AUTHORITY_B, ROOT_ID)
val permissionA = permission(treeA, read = true, write = false)
val permissionB = permission(treeB, read = true, write = false)
val resolver = resolverWith(permissionB, permissionA)
val requestedA = DocumentFile.fromTreeUri(context, treeA)!!.uri
PersistedPermissionResolver.release(resolver, requestedA, read = true, write = false)
verify(resolver).releasePersistableUriPermission(
treeA,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
verify(resolver, never()).releasePersistableUriPermission(
treeB,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
@Test
fun exactUrisResolveWhileUnrelatedMalformedUriDoesNot() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val singleDocumentUri = DocumentsContract.buildDocumentUri(AUTHORITY_A, "single:item")
val malformedPersistedUri = Uri.parse("content://$AUTHORITY_A/not-a-document/value")
val treePermission = permission(treeUri, read = true, write = false)
val singlePermission = permission(singleDocumentUri, read = true, write = false)
val malformedPermission = permission(malformedPersistedUri, read = true, write = false)
val resolver = resolverWith(treePermission, singlePermission, malformedPermission)
assertSame(treePermission, PersistedPermissionResolver.resolve(resolver, treeUri))
assertSame(
singlePermission,
PersistedPermissionResolver.resolve(resolver, singleDocumentUri)
)
assertSame(
malformedPermission,
PersistedPermissionResolver.resolve(resolver, malformedPersistedUri)
)
assertNull(
PersistedPermissionResolver.resolve(
resolver,
Uri.parse("content://$AUTHORITY_A/not-a-document/other")
)
)
}
@Test
fun queryAndReleaseIntersectRequestedModesWithHeldModes() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val requested = DocumentFile.fromTreeUri(context, treeUri)!!.uri
val readOnlyResolver = resolverWith(permission(treeUri, read = true, write = false))
assertTrue(
PersistedPermissionResolver.hasPermission(
readOnlyResolver,
requested,
checkRead = true,
checkWrite = false
)
)
assertFalse(
PersistedPermissionResolver.hasPermission(
readOnlyResolver,
requested,
checkRead = false,
checkWrite = true
)
)
assertFalse(
PersistedPermissionResolver.hasPermission(
readOnlyResolver,
requested,
checkRead = true,
checkWrite = true
)
)
PersistedPermissionResolver.release(
readOnlyResolver,
requested,
read = true,
write = true
)
verify(readOnlyResolver).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
val readWriteResolver = resolverWith(permission(treeUri, read = true, write = true))
PersistedPermissionResolver.release(
readWriteResolver,
requested,
read = true,
write = true
)
verify(readWriteResolver).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
val writeOnlyResolver = resolverWith(permission(treeUri, read = false, write = true))
PersistedPermissionResolver.release(
writeOnlyResolver,
requested,
read = false,
write = true
)
verify(writeOnlyResolver).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
@Test
fun noRequestedModesMakesNoPlatformReleaseCall() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val resolver = resolverWith(permission(treeUri, read = true, write = true))
PersistedPermissionResolver.release(resolver, treeUri, read = false, write = false)
verify(resolver, never()).releasePersistableUriPermission(any(Uri::class.java), anyInt())
}
@Test
fun repeatedAndMissingReleaseAreIdempotent() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val requested = DocumentFile.fromTreeUri(context, treeUri)!!.uri
val permission = permission(treeUri, read = true, write = false)
val resolver = mock(ContentResolver::class.java)
`when`(resolver.persistedUriPermissions).thenReturn(
listOf(permission),
emptyList(),
emptyList()
)
PersistedPermissionResolver.release(resolver, requested, read = true, write = true)
PersistedPermissionResolver.release(resolver, requested, read = true, write = true)
PersistedPermissionResolver.release(
resolver,
Uri.parse("content://$AUTHORITY_A/not-a-document/missing"),
read = true,
write = true
)
verify(resolver, times(1)).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
@Test
fun matchedPlatformReleaseErrorPropagates() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val resolver = resolverWith(permission(treeUri, read = true, write = false))
val failure = SecurityException("platform rejected release")
doThrow(failure).`when`(resolver).releasePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
val thrown =
assertFailsWith<SecurityException> {
PersistedPermissionResolver.release(resolver, treeUri, read = true, write = false)
}
assertSame(failure, thrown)
}
@Test
fun canonicalLookupAndEnumerationReturnExactPersistedUris() {
val treeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_A, ROOT_ID)
val otherTreeUri = DocumentsContract.buildTreeDocumentUri(AUTHORITY_B, "other:root")
val permission = permission(treeUri, read = true, write = true)
val otherPermission = permission(otherTreeUri, read = true, write = false)
val resolver = resolverWith(permission, otherPermission)
val descendant =
DocumentsContract.buildDocumentUriUsingTree(treeUri, "$ROOT_ID/child/file")
assertEquals(treeUri, PersistedPermissionResolver.resolveUri(resolver, descendant))
assertEquals(
listOf(treeUri, otherTreeUri),
PersistedPermissionResolver.getPersistedUris(resolver)
)
}
private fun resolverWith(vararg permissions: UriPermission): ContentResolver = mock(ContentResolver::class.java).also { resolver ->
`when`(resolver.persistedUriPermissions).thenReturn(permissions.toList())
}
private fun permission(
uri: Uri,
read: Boolean,
write: Boolean
): UriPermission = mock(UriPermission::class.java).also { permission ->
`when`(permission.uri).thenReturn(uri)
`when`(permission.isReadPermission).thenReturn(read)
`when`(permission.isWritePermission).thenReturn(write)
}
private companion object {
const val AUTHORITY_A = "provider.a.documents"
const val AUTHORITY_B = "provider.b.documents"
const val ROOT_ID = "primary:Movies"
}
}
@@ -1,17 +1,27 @@
package com.fluttercavalry.saf_util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.ParcelFileDescriptor
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.verifyNoMoreInteractions
@@ -104,6 +114,137 @@ internal class SafUtilPluginTest {
assertEquals(listOf(1001), secondActivity.startedRequestCodes)
}
@Test
fun extractVideoFrame_releasesRetrieverAfterSuccess() {
val context = mock(Context::class.java)
val uri = mock(Uri::class.java)
val frame = mock(Bitmap::class.java)
val retriever = mock(MediaMetadataRetriever::class.java)
`when`(retriever.frameAtTime).thenReturn(frame)
val extracted = extractVideoFrame(context, uri, 320, 180, retriever)
assertEquals(frame, extracted)
verify(retriever).setDataSource(context, uri)
verify(retriever).release()
}
@Test
fun extractVideoFrame_releasesRetrieverWhenDataSourceThrows() {
val context = mock(Context::class.java)
val uri = mock(Uri::class.java)
val retriever = mock(MediaMetadataRetriever::class.java)
val failure = IllegalStateException("invalid source")
doThrow(failure).`when`(retriever).setDataSource(context, uri)
val thrown =
assertFailsWith<IllegalStateException> {
extractVideoFrame(context, uri, 320, 180, retriever)
}
assertEquals(failure, thrown)
verify(retriever).release()
}
@Test
fun extractVideoFrame_releasesRetrieverWhenFrameExtractionThrows() {
val context = mock(Context::class.java)
val uri = mock(Uri::class.java)
val retriever = mock(MediaMetadataRetriever::class.java)
val failure = IllegalStateException("extract failed")
`when`(retriever.frameAtTime).thenThrow(failure)
val thrown =
assertFailsWith<IllegalStateException> {
extractVideoFrame(context, uri, 320, 180, retriever)
}
assertEquals(failure, thrown)
verify(retriever).release()
}
@Test
fun extractVideoFrame_releasesRetrieverWhenFrameIsNull() {
val context = mock(Context::class.java)
val uri = mock(Uri::class.java)
val retriever = mock(MediaMetadataRetriever::class.java)
`when`(retriever.frameAtTime).thenReturn(null)
assertNull(extractVideoFrame(context, uri, 320, 180, retriever))
verify(retriever).release()
}
@Test
fun closeFileDescriptor_isIdempotent() {
val registry = FileDescriptorRegistry()
val descriptor = descriptor(42)
registry.attach()
assertEquals(42, registry.register(descriptor))
registry.close(42)
registry.close(42)
registry.close(99)
verify(descriptor, times(1)).close()
assertEquals(0, registry.trackedCount)
}
@Test
fun engineDetach_closesAndClearsEveryDescriptorBestEffort() {
val registry = FileDescriptorRegistry()
val failingDescriptor = descriptor(42)
val laterDescriptor = descriptor(43)
doThrow(IOException("close failed")).`when`(failingDescriptor).close()
registry.attach()
registry.register(failingDescriptor)
registry.register(laterDescriptor)
registry.detach()
registry.close(42)
registry.close(43)
verify(failingDescriptor, times(1)).close()
verify(laterDescriptor, times(1)).close()
assertEquals(0, registry.trackedCount)
}
@Test
fun descriptorCompletingAfterDetach_isRejectedAndClosed() {
val registry = FileDescriptorRegistry()
val descriptor = descriptor(42)
registry.attach()
registry.detach()
val registeredFd = registry.register(descriptor)
assertNull(registeredFd)
verify(descriptor, times(1)).close()
assertEquals(0, registry.trackedCount)
}
@Test
fun engineReattach_startsWithEmptyDescriptorOwnership() {
val registry = FileDescriptorRegistry()
val firstDescriptor = descriptor(42)
val secondDescriptor = descriptor(43)
registry.attach()
registry.register(firstDescriptor)
registry.detach()
registry.attach()
assertEquals(43, registry.register(secondDescriptor))
registry.close(43)
verify(firstDescriptor, times(1)).close()
verify(secondDescriptor, times(1)).close()
assertEquals(0, registry.trackedCount)
}
private fun descriptor(fd: Int): ParcelFileDescriptor = mock(ParcelFileDescriptor::class.java).also { descriptor ->
`when`(descriptor.fd).thenReturn(fd)
}
private class RecordingActivity : Activity() {
val startedRequestCodes = mutableListOf<Int>()