Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package co.mearman.cascade
import android.provider.DocumentsContract
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
Expand All @@ -13,7 +14,9 @@ import org.junit.runner.RunWith
* ContentResolver, so it exercises the full provider -> CascadeNode -> FFI ->
* engine -> local-backend stack on an actual Android runtime (the emulator in
* CI), with the native .so loaded by JNA. Seeds a file under the node's files
* dir and asserts it surfaces through queryRoots/queryChildDocuments.
* dir and asserts it surfaces through queryRoots/queryChildDocuments, then drives
* a create -> list -> rename -> delete round-trip through the Storage Access
* Framework's DocumentsContract to exercise the write verbs end to end.
*/
@RunWith(AndroidJUnit4::class)
class DocumentsProviderInstrumentedTest {
Expand All @@ -22,6 +25,18 @@ class DocumentsProviderInstrumentedTest {

private fun rootsUri() = DocumentsContract.buildRootsUri(authority)

private fun childNames(parentDocumentId: String): List<String> {
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
val children = DocumentsContract.buildChildDocumentsUri(authority, parentDocumentId)
val names = mutableListOf<String>()
ctx.contentResolver.query(
children,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null, null, null,
)?.use { c -> while (c.moveToNext()) names += c.getString(0) }
return names
}

@Test
fun queryRoots_advertisesTheCascadeRoot() {
val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver
Expand All @@ -41,17 +56,83 @@ class DocumentsProviderInstrumentedTest {
val filesRoot = java.io.File(ctx.filesDir, "files").apply { mkdirs() }
java.io.File(filesRoot, "e2e-probe.txt").writeText("hello e2e")

// The local backend is mounted at "/local"; list its children. (The root
// document "/" lists the mount points, so its child is "local".)
val children = DocumentsContract.buildChildDocumentsUri(authority, "/local")
val names = mutableListOf<String>()
ctx.contentResolver.query(
children,
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
null, null, null,
)?.use { c -> while (c.moveToNext()) names += c.getString(0) }
val names = childNames("/local")

assertNotNull("listing returned a cursor", names)
assertTrue("seeded file surfaced via the provider: $names", names.any { it == "e2e-probe.txt" })
}

@Test
fun createDocument_renameDocument_deleteDocument_roundTrip() {
val ctx = InstrumentationRegistry.getInstrumentation().targetContext
val resolver = ctx.contentResolver
val parentDocId = "/local"

// createDocument (a directory) under /local.
val createdDocId = DocumentsContract.createDocument(
resolver,
DocumentsContract.buildDocumentUri(authority, parentDocId),
DocumentsContract.Document.MIME_TYPE_DIR,
"saf-roundtrip-dir",
)?.lastPathSegment
?: error("createDocument returned null URI")

try {
assertTrue(
"createDocument produced a child doc id: $createdDocId",
createdDocId == "/local/saf-roundtrip-dir",
)

// The new directory must surface in a fresh listing.
assertTrue(
"created dir lists: ${childNames(parentDocId)}",
childNames(parentDocId).any { it == "saf-roundtrip-dir" },
)

// renameDocument: rename the directory and confirm the new id.
val renamedDocId = DocumentsContract.renameDocument(
resolver,
DocumentsContract.buildDocumentUri(authority, createdDocId),
"saf-roundtrip-renamed",
)?.lastPathSegment
?: error("renameDocument returned null URI")

assertTrue(
"rename produced the renamed doc id: $renamedDocId",
renamedDocId == "/local/saf-roundtrip-renamed",
)
assertFalse(
"old name gone after rename: ${childNames(parentDocId)}",
childNames(parentDocId).any { it == "saf-roundtrip-dir" },
)
assertTrue(
"renamed dir lists: ${childNames(parentDocId)}",
childNames(parentDocId).any { it == "saf-roundtrip-renamed" },
)

// deleteDocument: remove and confirm it no longer lists.
DocumentsContract.deleteDocument(
resolver,
DocumentsContract.buildDocumentUri(authority, renamedDocId),
)
assertFalse(
"deleted dir is gone: ${childNames(parentDocId)}",
childNames(parentDocId).any { it == "saf-roundtrip-renamed" },
)
} finally {
// Clean up anything left behind if an assertion threw.
runCatching {
DocumentsContract.deleteDocument(
resolver,
DocumentsContract.buildDocumentUri(authority, createdDocId),
)
}
runCatching {
DocumentsContract.deleteDocument(
resolver,
DocumentsContract.buildDocumentUri(authority, "/local/saf-roundtrip-renamed"),
)
}
}
}
}
115 changes: 101 additions & 14 deletions android/app/src/main/java/co/mearman/cascade/CascadeDocumentsProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import android.database.MatrixCursor
import android.graphics.Point
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import android.util.Log
import kotlinx.coroutines.runBlocking
import uniffi.cascade_ffi.CascadeException
import uniffi.cascade_ffi.CascadeNode
import uniffi.cascade_ffi.DirEntry
import java.io.FileNotFoundException

Expand All @@ -19,12 +23,19 @@ import java.io.FileNotFoundException
* as the set of mounted backend prefixes (the in-process node mounts a single
* local backend under the `local` prefix). Listings call [CascadeNode.listDir] and
* file reads call [CascadeNode.readFile]; nothing here is faked.
*
* Write verbs (create/delete/rename) are wired through the same node's
* [CascadeNode.createDir]/[CascadeNode.delete]/[CascadeNode.rename]. Their
* availability is advertised on each cursor row by [CursorBuilder.flagsForDir] /
* [CursorBuilder.flagsForFile] so the Files app surfaces the actions.
*/
class CascadeDocumentsProvider : DocumentsProvider() {

private companion object {
const val TAG = "CascadeProvider"
const val ROOT_ID = "cascade"
const val ROOT_DOC_ID = "/"
const val AUTHORITY = "co.mearman.cascade.documents"

val DEFAULT_ROOT_PROJECTION = arrayOf(
Root.COLUMN_ROOT_ID,
Expand Down Expand Up @@ -93,24 +104,30 @@ class CascadeDocumentsProvider : DocumentsProvider() {
val node = CascadeNodeHolder.blockingGet(requireContext())
val isWrite = mode.contains('w') || mode.contains('+')
if (isWrite) {
// Write-back: hand the caller a pipe to write into, and on close
// upload the captured bytes back through the FFI. This bridges the
// SAF "w"/"rw" open mode onto CascadeNode.upload.
// Write-back: hand the caller the write end of a reliable pipe and,
// on a background thread, drain the read end into upload(). The
// reliable pipe carries the comm channel that closeWithError()
// writes to, so an upload failure can be propagated back to the
// caller rather than vanishing into the thread's uncaught handler.
val pipe = ParcelFileDescriptor.createReliablePipe()
val readSide = pipe[0]
val writeSide = pipe[1]
// The caller writes into writeSide; we read from readSide on a
// background thread and upload when the writer closes.
Thread({
ParcelFileDescriptor.AutoCloseInputStream(readSide).use { input ->
val bytes = input.readBytes()
runBlocking {
try {
node.upload(documentId, bytes)
} catch (e: CascadeException) {
// Surface as a write-side error so the caller sees
// the upload failed rather than silently dropping.
throw FileNotFoundException("upload($documentId) failed: ${e.message}")
try {
runBlocking { node.upload(documentId, bytes) }
} catch (e: CascadeException) {
// The caller has already closed its write end by the time
// we get here, so it believes the write succeeded. Log
// loudly, and push the failure across the reliable-pipe
// comm channel so any caller observing checkError()
// sees it. closeWithError() throws IOException, which we
// swallow here only because the failure has already been
// recorded via Log.e and the pipe status.
Log.e(TAG, "write-back upload failed for $documentId: ${e.message}", e)
runCatching {
readSide.closeWithError("upload($documentId) failed: ${e.message}")
}
}
}
Expand All @@ -134,14 +151,84 @@ class CascadeDocumentsProvider : DocumentsProvider() {
ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { out ->
try {
out.write(bytes)
} catch (_: Exception) {
// The reader closed early; nothing to recover.
} catch (e: Exception) {
// The reader closed early; the upload path owns write-back
// errors, this is just the read-side stream draining.
Log.w(TAG, "read-side stream interrupted for $documentId: ${e.message}")
}
}
}, "cascade-openDocument").start()
return readSide
}

override fun createDocument(
parentDocumentId: String,
mimeType: String,
displayName: String,
): String {
val node = CascadeNodeHolder.blockingGet(requireContext())
val childDocId = DocIdLogic.childDocId(parentDocumentId, displayName)
runBlocking {
try {
if (Document.MIME_TYPE_DIR == mimeType) {
node.createDir(childDocId)
} else {
// SAF lets the user create a new (empty) file; model that as
// an upload of zero bytes so the node's backend creates it.
node.upload(childDocId, ByteArray(0))
}
} catch (e: CascadeException) {
throw IllegalStateException(
"createDocument($childDocId, mime=$mimeType) failed: ${e.message}",
e,
)
}
}
notifyChange(childDocId)
return childDocId
}

override fun deleteDocument(documentId: String) {
val node = CascadeNodeHolder.blockingGet(requireContext())
runBlocking {
try {
node.delete(documentId)
} catch (e: CascadeException) {
throw IllegalStateException("deleteDocument($documentId) failed: ${e.message}", e)
}
}
notifyChange(documentId)
}

override fun renameDocument(documentId: String, displayName: String): String {
val node = CascadeNodeHolder.blockingGet(requireContext())
val parent = parentOf(documentId)
val newDocId = DocIdLogic.childDocId(parent, displayName)
runBlocking {
try {
node.rename(documentId, newDocId)
} catch (e: CascadeException) {
throw IllegalStateException(
"renameDocument($documentId -> $newDocId) failed: ${e.message}",
e,
)
}
}
notifyChange(documentId)
notifyChange(newDocId)
return newDocId
}

/**
* Tell the system the subtree rooted at [documentId] changed so the Files
* app re-queries and shows the new state. Uses the standard
* `notifyChange` URI for a document so any open cursor is invalidated.
*/
private fun notifyChange(documentId: String) {
val uri = DocumentsContract.buildDocumentUri(AUTHORITY, documentId)
context?.contentResolver?.notifyChange(uri, null)
}

private fun parentOf(documentId: String): String = DocIdLogic.parentOf(documentId)

private fun nameOf(documentId: String): String = DocIdLogic.nameOf(documentId)
Expand Down
30 changes: 24 additions & 6 deletions android/app/src/main/java/co/mearman/cascade/CursorBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@ internal object CursorBuilder {
add(Document.COLUMN_DOCUMENT_ID, docId)
add(Document.COLUMN_DISPLAY_NAME, entry.name)
add(Document.COLUMN_MIME_TYPE, mimeOf(entry.name))
// FLAG_SUPPORTS_WRITE is honoured via openDocument's write-back
// path, which uploads on close. Create/delete/rename need a
// custom SAF call surface the provider does not yet expose, so
// those flags are omitted to avoid advertising unsupported verbs.
add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_WRITE)
add(Document.COLUMN_FLAGS, flagsForFile())
add(Document.COLUMN_SIZE, null)
}
}
Expand All @@ -74,11 +70,33 @@ internal object CursorBuilder {
add(Document.COLUMN_DOCUMENT_ID, docId)
add(Document.COLUMN_DISPLAY_NAME, displayName)
add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
add(Document.COLUMN_FLAGS, 0)
add(Document.COLUMN_FLAGS, flagsForDir())
add(Document.COLUMN_SIZE, null)
}
}

/**
* Flags advertised on a file row. `FLAG_SUPPORTS_WRITE` is honoured via
* [CascadeDocumentsProvider.openDocument]'s write-back path (upload on
* close). `FLAG_SUPPORTS_DELETE` and `FLAG_SUPPORTS_RENAME` are honoured by
* the provider's `deleteDocument`/`renameDocument` overrides.
*/
fun flagsForFile(): Int =
Document.FLAG_SUPPORTS_WRITE or
Document.FLAG_SUPPORTS_DELETE or
Document.FLAG_SUPPORTS_RENAME

/**
* Flags advertised on a directory row. `FLAG_DIR_SUPPORTS_CREATE` lets the
* Files app offer "new folder"/"new file", honoured by the provider's
* `createDocument` override (folders via `create_dir`, files via an empty
* upload). Directories are also deletable and renamable.
*/
fun flagsForDir(): Int =
Document.FLAG_DIR_SUPPORTS_CREATE or
Document.FLAG_SUPPORTS_DELETE or
Document.FLAG_SUPPORTS_RENAME

/** Infer a MIME type from a filename's extension, falling back to a stream. */
fun mimeOf(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase()
Expand Down
Loading
Loading