fix(runtime): harden application service boundaries
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
+59
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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
|
||||
)
|
||||
}
|
||||
+58
-63
@@ -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
|
||||
|
||||
+25
@@ -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()
|
||||
}
|
||||
+266
@@ -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"
|
||||
}
|
||||
}
|
||||
+141
@@ -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>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user