diff --git a/docs/core/platform-file.mdx b/docs/core/platform-file.mdx index 1a71ddbe..7705bb69 100644 --- a/docs/core/platform-file.mdx +++ b/docs/core/platform-file.mdx @@ -106,6 +106,8 @@ val mimeType: MimeType? = file.mimeType() On Android, `name` is resolved using provider metadata. For Photo Picker URIs, FileKit performs a best-effort lookup of `MediaStore.MediaColumns.DISPLAY_NAME` when providers return synthetic names (for example `18.jpg`), then falls back to a stable provider/URI-derived name if the original filename is not accessible. +For Android content URIs, `parent()` supports external-storage document trees and preserves access to the selected tree. It returns `null` at the selected directory's root and for other content providers whose parent cannot be determined from the URI. Filesystem paths continue to use normal filesystem parents. + On JS/WASM, directory picker results are virtual directory trees built from browser file entries. Empty directories are not exposed by browsers, and directory `lastModified()` values are synthetic. ## File operations diff --git a/filekit-core/src/androidHostTest/kotlin/io/github/vinceglb/filekit/PlatformFileParentAndroidTest.kt b/filekit-core/src/androidHostTest/kotlin/io/github/vinceglb/filekit/PlatformFileParentAndroidTest.kt new file mode 100644 index 00000000..319d6798 --- /dev/null +++ b/filekit-core/src/androidHostTest/kotlin/io/github/vinceglb/filekit/PlatformFileParentAndroidTest.kt @@ -0,0 +1,246 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Issue #655: exercises real FileKit navigation against a simulated SAF tree grant. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class PlatformFileParentAndroidTest { + @Before + fun setup() { + ShadowContentResolver.reset() + FileKit.manualFileKitCoreInitialization(RuntimeEnvironment.getApplication()) + } + + @Test + fun PlatformFile_parent_childOfDocuments_listsSelectedDirectory() { + val selected = selectTree("primary:Documents") + val child = selected.list().single { it.name == "Notes" } + + val parent = assertNotNull(child.parent()) + + assertEquals(selected.list().map { it.name }, parent.list().map { it.name }) + } + + @Test + fun PlatformFile_parent_childOfNestedSelection_listsSelectedDirectory() { + val selected = selectTree("primary:Documents/Notes") + val child = selected.list().single { it.name == "Drafts" } + assertEquals(listOf("note.txt"), child.list().map { it.name }) + + val parent = assertNotNull(child.parent()) + + assertEquals(selected.list().map { it.name }, parent.list().map { it.name }) + } + + @Test + fun PlatformFile_parent_grandchildOfDocuments_returnsImmediateParent() { + val selected = selectTree("primary:Documents") + val notes = selected.list().single { it.name == "Notes" } + val drafts = notes.list().single { it.name == "Drafts" } + + val parent = assertNotNull(drafts.parent()) + + assertEquals(notes.path, parent.path) + assertEquals(notes.list().map { it.name }, parent.list().map { it.name }) + } + + @Test + fun PlatformFile_list_documentUriOfNestedTreeRoot_listsSelectedDirectory() { + val selected = selectTree("primary:Documents/Notes") + val documentUri = DocumentsContract.buildDocumentUriUsingTree( + Uri.parse(selected.path), + "primary:Documents/Notes", + ) + + val root = PlatformFile(documentUri) + + // Both URI forms identify the same selected directory, with the same grant. + assertTrue(root.exists()) + assertTrue(root.isDirectory()) + assertFalse(root.isRegularFile()) + assertEquals(selected.list().map { it.name }, root.list().map { it.name }) + } + + @Test + @Config(sdk = [23]) + fun PlatformFile_parent_childOfNestedSelectionOnApi23_listsSelectedDirectory() { + val selected = selectTree("primary:Documents/Notes") + val child = selected.list().single { it.name == "Drafts" } + + val parent = assertNotNull(child.parent()) + + assertEquals(selected.list().map { it.name }, parent.list().map { it.name }) + } + + @Test + fun PlatformFile_parent_selectedRootInEitherUriForm_returnsNull() { + val selected = selectTree("primary:Documents/Notes") + val documentUri = DocumentsContract.buildDocumentUriUsingTree( + Uri.parse(selected.path), + "primary:Documents/Notes", + ) + + assertNull(selected.parent()) + assertNull(PlatformFile(documentUri).parent()) + } + + @Test + fun PlatformFile_parent_fileWithEncodedCharacters_preservesParentAndGrant() { + val treeId = "primary:Documents/Notes #1%" + val parentId = "$treeId/été + drafts" + val treeUri = DocumentsContract.buildTreeDocumentUri(PARENT_TEST_AUTHORITY, treeId) + val file = PlatformFile(DocumentsContract.buildDocumentUriUsingTree(treeUri, "$parentId/note.txt")) + + val parent = assertNotNull(file.parent()) + + assertEquals(DocumentsContract.buildDocumentUriUsingTree(treeUri, parentId).toString(), parent.path) + } + + @Test + fun PlatformFile_parent_childOfStorageVolumeRoot_returnsGrantedRoot() { + val treeUri = DocumentsContract.buildTreeDocumentUri(PARENT_TEST_AUTHORITY, "1234-ABCD:") + val child = PlatformFile(DocumentsContract.buildDocumentUriUsingTree(treeUri, "1234-ABCD:Notes")) + + val parent = assertNotNull(child.parent()) + + assertEquals(DocumentsContract.buildDocumentUriUsingTree(treeUri, "1234-ABCD:").toString(), parent.path) + assertNull(parent.parent()) + } + + @Test + fun PlatformFile_parent_documentOutsideGrantedTree_returnsNull() { + val treeUri = DocumentsContract.buildTreeDocumentUri(PARENT_TEST_AUTHORITY, "primary:Documents/Notes") + val outside = PlatformFile( + DocumentsContract.buildDocumentUriUsingTree(treeUri, "primary:Documents/NotesOther/note.txt"), + ) + + assertNull(outside.parent()) + } + + @Test + fun PlatformFile_parent_nonTreeContentUri_returnsNull() { + val document = PlatformFile(DocumentsContract.buildDocumentUri(PARENT_TEST_AUTHORITY, "primary:Documents/note.txt")) + val media = PlatformFile(Uri.parse("content://media/external/images/media/42")) + + assertNull(document.parent()) + assertNull(media.parent()) + } + + @Test + fun PlatformFile_parent_opaqueProviderIds_returnsNull() { + val treeUri = DocumentsContract.buildTreeDocumentUri("com.example.documents", "root") + val child = PlatformFile(DocumentsContract.buildDocumentUriUsingTree(treeUri, "root/opaque-id")) + + assertNull(child.parent()) + } + + private fun selectTree(documentId: String): PlatformFile { + val provider = ParentNavigationProvider(documentId).apply { + attachInfo( + RuntimeEnvironment.getApplication(), + ProviderInfo().apply { authority = PARENT_TEST_AUTHORITY }, + ) + } + ShadowContentResolver.registerProviderInternal(PARENT_TEST_AUTHORITY, provider) + return PlatformFile(DocumentsContract.buildTreeDocumentUri(PARENT_TEST_AUTHORITY, documentId)) + } +} + +/** + * Models a picker grant to one tree only. Robolectric does not enforce Android's + * URI grants here, so the provider rejects queries using a different tree or + * targeting documents outside the selected tree. It does not mock parent/list. + */ +private class ParentNavigationProvider( + private val grantedTreeId: String, +) : ContentProvider() { + private val documents = linkedMapOf( + "primary:Documents" to true, + "primary:Documents/Notes" to true, + "primary:Documents/Notes/Drafts" to true, + "primary:Documents/Notes/Drafts/note.txt" to false, + ) + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + val documentId = DocumentsContract.getDocumentId(uri) + if (DocumentsContract.getTreeDocumentId(uri) != grantedTreeId || + (documentId != grantedTreeId && !documentId.startsWith("$grantedTreeId/")) + ) { + throw SecurityException("No picker grant for $uri") + } + + val columns = projection ?: arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ) + return MatrixCursor(columns).apply { + documents.forEach { (id, isDirectory) -> + val matches = if (uri.lastPathSegment == "children") { + id.substringBeforeLast('/', "") == documentId + } else { + id == documentId + } + if (matches) { + addRow( + columns + .map { column -> + when (column) { + DocumentsContract.Document.COLUMN_DOCUMENT_ID -> id + + DocumentsContract.Document.COLUMN_DISPLAY_NAME -> id.substringAfterLast('/').substringAfter(':') + + DocumentsContract.Document.COLUMN_MIME_TYPE -> if (isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + "text/plain" + } + + else -> null + } + }.toTypedArray(), + ) + } + } + } + } + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} + +private const val PARENT_TEST_AUTHORITY = "com.android.externalstorage.documents" diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt index 74b1866e..3435decb 100644 --- a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt +++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt @@ -199,15 +199,7 @@ public actual fun PlatformFile.parent(): PlatformFile? = when (androidFile) { } is AndroidFile.UriWrapper -> { - val uri = androidFile.uri - val parentUri = uri - .buildUpon() - .path(uri.path?.substringBeforeLast('/')) - .build() - - DocumentFile.fromTreeUri(FileKit.context, parentUri)?.let { - PlatformFile(it.uri) - } + androidFile.uri.parentDocumentUriOrNull()?.let(::PlatformFile) } } @@ -1065,6 +1057,24 @@ private fun Uri.queryDocumentInfos(childrenUri: Uri): List emptyList() } +private fun Uri.parentDocumentUriOrNull(): Uri? { + // External storage document IDs have the form volume:path. + // Other providers may use opaque IDs that do not encode a parent. + if (!isTreeUriCompat() || authority != "com.android.externalstorage.documents") { + return null + } + + val treeDocumentId = treeDocumentId() + val documentId = documentId() + val treePrefix = if (treeDocumentId.endsWith(':')) treeDocumentId else "$treeDocumentId/" + if (documentId == treeDocumentId || !documentId.startsWith(treePrefix)) { + return null + } + + val parentDocumentId = documentId.substringBeforeLast('/', missingDelimiterValue = treeDocumentId) + return DocumentsContract.buildDocumentUriUsingTree(this, parentDocumentId) +} + private fun Uri.parentDocumentUriAndName(): Pair { val (parentDocumentId, childName) = parentDocumentIdAndNameOrNull() ?: throw FileKitException("Uri does not describe a child document: $this") @@ -1078,6 +1088,10 @@ private fun Uri.parentDocumentIdAndNameOrNull(): Pair? { } catch (_: IllegalArgumentException) { return null } + // The selected tree root has no parent within the grant, even if its ID contains '/'. + if (isTreeUriCompat() && documentId == treeDocumentId()) { + return null + } val parentDocumentId = documentId.substringBeforeLast('/', missingDelimiterValue = "") val childName = documentId.substringAfterLast('/') diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt index 28c838a8..b8cd00ea 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt @@ -73,7 +73,10 @@ public expect fun PlatformFile.mimeType(): MimeType? public expect val PlatformFile.path: String /** - * Returns the parent of this file, or null if it does not have a parent. + * Returns the parent of this file, or null if it has no parent or its parent cannot be determined. + * + * On Android, content URI parents are supported for external-storage document trees. + * Returns null at the selected tree root and for other content URIs. * * @return The parent [PlatformFile], or null. */