diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-09-10 23:46:21 +0200 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-09-10 23:50:27 +0200 |
| commit | 994672608db65a68b3ba3db8fa37bb613de89c20 (patch) | |
| tree | 4873d7177a7949f7e1501e9494e2897e2da35d03 /libs | |
| parent | 3e1b734cd804dbdb8bff8bbdc944a0fd141bed75 (diff) | |
Add libs:documents
Reads the abomination that is SAF, or Androids best effort to make
files and directories completely and utterly unusable on Android.
The androidTest was (and is) a pain, only known to work on a
Pixel3 API 34 emulator but it showed a lot of things that the
fake content provider in the unit tests failed to show.
Diffstat (limited to 'libs')
16 files changed, 1333 insertions, 18 deletions
diff --git a/libs/documents/.gitignore b/libs/documents/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/libs/documents/.gitignore @@ -0,0 +1 @@ +/build
\ No newline at end of file diff --git a/libs/documents/build.gradle.kts b/libs/documents/build.gradle.kts new file mode 100644 index 0000000..8d03de9 --- /dev/null +++ b/libs/documents/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "org.the_jk.cleversync.documents" + + testOptions { + unitTests { + all { test -> + // Needed to get robolectric FileDescriptorInterceptor to work + test.jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED") + } + } + } +} + +dependencies { + implementation(project(":libs:io")) + implementation(libs.androidx.core) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.uiautomator) + testImplementation(project(":libs:test-utils")) + androidTestImplementation(project(":libs:test-utils")) +} + +val removeTestDirs = tasks.register<Exec>("removeTestDirs") { + executable = android.adbExecutable.toString() + args("shell", "rm", "-vrf", "/sdcard/Download/DocumentTreeTest-*") +} + +tasks.all { + if (name == "connectedDebugAndroidTest") { + finalizedBy(removeTestDirs) + } +} diff --git a/libs/documents/consumer-rules.pro b/libs/documents/consumer-rules.pro new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/documents/consumer-rules.pro diff --git a/libs/documents/proguard-rules.pro b/libs/documents/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/libs/documents/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile
\ No newline at end of file diff --git a/libs/documents/src/androidTest/AndroidManifest.xml b/libs/documents/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..ddb7408 --- /dev/null +++ b/libs/documents/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application> + <activity android:name="org.the_jk.cleversync.documents.test.TestActivity" /> + </application> +</manifest> diff --git a/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/DocumentTreeAndroidTest.kt b/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/DocumentTreeAndroidTest.kt new file mode 100644 index 0000000..d0d6bb3 --- /dev/null +++ b/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/DocumentTreeAndroidTest.kt @@ -0,0 +1,216 @@ +package org.the_jk.cleversync.documents + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.os.Handler +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.the_jk.cleversync.TreeAbstractTest +import org.the_jk.cleversync.documents.test.TestActivity +import java.io.File +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class DocumentTreeAndroidTest : TreeAbstractTest() { + @get:Rule + val activityRule = ActivityScenarioRule(TestActivity::class.java) + + private lateinit var testDir: File + private lateinit var mainHandler: Handler + + @Before + fun setUp() { + // If tests fail in a certain way we lose the right to download the + // directory which confuses the rest of the tests as they expect + // an empty directory. Better, create a new directory if needed. + // Build script tries to remove them all after a run. + var i = 0 + while (true) { + testDir = File(Environment.getExternalStorageDirectory(), "Download/DocumentTreeTest-$i") + if (!testDir.exists()) break + testDir.deleteRecursively() + if (!testDir.exists()) break + i++ + } + testDir.mkdirs() + } + + @After + fun tearDown() { + testDir.deleteRecursively() + } + + @Test + override fun empty() { + runTest { super.empty() } + } + + @Test + override fun emptyLive() { + runTest { super.emptyLive() } + } + + @Test + override fun createDirectory() { + runTest { super.createDirectory() } + } + + @Test(timeout = 30000) + override fun observeCreateDirectory() { + runTest { super.observeCreateDirectory() } + } + + @Test + override fun createFile() { + runTest { super.createFile() } + } + + @Test + override fun overwriteFile() { + runTest { super.overwriteFile() } + } + + @Test + override fun removeDir() { + runTest { super.removeDir() } + } + + @Test(timeout = 30000) + override fun removeDirLive() { + runTest { super.removeDirLive() } + } + + @Test + override fun sameDir() { + runTest { super.sameDir() } + } + + @Test + override fun sameFile() { + runTest { super.sameFile() } + } + + @Test + override fun removeDirWithContent() { + runTest { super.removeDirWithContent() } + } + + @Test + override fun removeWrongType() { + runTest { super.removeWrongType() } + } + + @Test + override fun removeFile() { + runTest { super.removeFile() } + } + + @Test + override fun names() { + runTest { super.names() } + } + + @Test + override fun openNonExistent() { + runTest { super.openNonExistent() } + } + + @Test + override fun lastModified() { + runTest { super.lastModified() } + } + + override fun supportSymlinks() = false + + override fun idle() { + // Called on mainLooper while waiting for testLooper, should usually + // not happen as the events we wait for have often already happened + // but make sure to not busy loop. + Thread.sleep(1000) + } + + override fun onMain(block: () -> Unit) { + mainHandler.post(block) + } + + // TODO: Figure out how to do this with @Before and @After + private fun runTest(body: () -> Unit) { + val context = ApplicationProvider.getApplicationContext<Context>() + val contentResolver = context.contentResolver + var bodyException: Throwable? = null + + val fullPath = testDir.toString() + val index = fullPath.indexOf("/Download/") + val path = fullPath.substring(index + 1) + val initialUri = Uri.Builder() + .scheme("content") + .authority("com.android.externalstorage.documents") + .encodedPath("document/primary%3A" + path.replace("/", "%2F")) + .build() + + Looper.prepare() + val testLooper = Looper.myLooper()!! + + activityRule.scenario.onActivity { activity -> + mainHandler = Handler(Looper.getMainLooper()) + + activity.setReceiver { uri -> + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + + tree = DocumentTreeFactory.createModifiableTree( + contentResolver, + uri, + 1.seconds, + ) + + try { + body() + } catch (e: Throwable) { + // Exceptions here are not well handled, re-throw + // outside scenario + bodyException = e + } + + // Quit the test loop when main looper is idle + Looper.getMainLooper().queue.addIdleHandler { + testLooper.quit() + false + } + } + activity.launcher.launch(initialUri) + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.wait( + Until.hasObject(By.pkg("com.google.android.documentsui").depth(0)), + 10000, + ) + + device.findObject(By.text("USE THIS FOLDER").clazz("android.widget.Button")).clickAndWait( + Until.newWindow(), + 500, + ) + + device.findObject(By.text("ALLOW").clazz("android.widget.Button")).click() + } + + Looper.loop() + + bodyException?.let { throw it } + } +} diff --git a/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/test/TestActivity.kt b/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/test/TestActivity.kt new file mode 100644 index 0000000..d6e60cb --- /dev/null +++ b/libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/test/TestActivity.kt @@ -0,0 +1,26 @@ +package org.the_jk.cleversync.documents.test + +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.FragmentActivity + +class TestActivity : FragmentActivity() { + lateinit var launcher: ActivityResultLauncher<Uri?> + private var receiver: ((Uri) -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + launcher = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri -> + if (uri != null) receiver?.invoke(uri) + } + } + + fun setReceiver(receiver: (Uri) -> Unit) { + this.receiver = receiver + } +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/documents/DocumentTreeFactory.kt b/libs/documents/src/main/java/org/the_jk/cleversync/documents/DocumentTreeFactory.kt new file mode 100644 index 0000000..45064aa --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/documents/DocumentTreeFactory.kt @@ -0,0 +1,33 @@ +package org.the_jk.cleversync.documents + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import org.the_jk.cleversync.io.ModifiableTree +import org.the_jk.cleversync.io.Tree +import org.the_jk.cleversync.io.documents.DocumentTree +import org.the_jk.cleversync.io.documents.getMetadata +import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +object DocumentTreeFactory { + fun tree(contentResolver: ContentResolver, treeUri: Uri): Tree { + return modifiableTree(contentResolver, treeUri) + } + + fun modifiableTree(contentResolver: ContentResolver, treeUri: Uri): ModifiableTree { + return createModifiableTree(contentResolver, treeUri, liveUpdateInterval = 10.seconds) + } + + internal fun createModifiableTree( + contentResolver: ContentResolver, + treeUri: Uri, + liveUpdateInterval: Duration, + ): ModifiableTree { + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, + DocumentsContract.getTreeDocumentId(treeUri)) + val metadata = contentResolver.getMetadata(documentUri) ?: throw IOException("Unable to open dir") + return DocumentTree(contentResolver, treeUri, metadata, liveUpdateInterval) + } +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DelayedCreationDocumentFile.kt b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DelayedCreationDocumentFile.kt new file mode 100644 index 0000000..9fdfb91 --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DelayedCreationDocumentFile.kt @@ -0,0 +1,36 @@ +package org.the_jk.cleversync.io.documents + +import org.the_jk.cleversync.io.ModifiableFile +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.time.Instant + +internal class DelayedCreationDocumentFile constructor( + private val metadata: DocumentMetadata, + private val create: () -> DocumentFile +) : ModifiableFile { + private var file: DocumentFile? = null + + override fun read(): InputStream { + return file?.read() ?: throw IOException("File does not exist") + } + + override fun write(): OutputStream { + if (file == null) { + file = create() + } + return file!!.write() + } + + override val name: String + get() = file?.name ?: metadata.displayName + override val size: ULong + get() = file?.size ?: metadata.size ?: 0UL + override val lastModified: Instant + get() = file?.lastModified ?: metadata.lastModified ?: Instant.EPOCH + + override fun equals(other: Any?) = file?.equals(other) ?: (this === other) + override fun hashCode(): Int = file?.hashCode() ?: super.hashCode() + override fun toString(): String = file?.toString() ?: metadata.displayName +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentDirectory.kt b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentDirectory.kt new file mode 100644 index 0000000..258d5b6 --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentDirectory.kt @@ -0,0 +1,302 @@ +package org.the_jk.cleversync.io.documents + +import android.content.ContentResolver +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract +import android.webkit.MimeTypeMap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import org.the_jk.cleversync.io.Directory +import org.the_jk.cleversync.io.File +import org.the_jk.cleversync.io.Link +import org.the_jk.cleversync.io.ModifiableDirectory +import org.the_jk.cleversync.io.ModifiableFile +import org.the_jk.cleversync.io.ModifiableLink +import java.io.IOException +import kotlin.time.Duration + +internal open class DocumentDirectory( + private val contentResolver: ContentResolver, + private val treeUri: Uri, + private val metadata: DocumentMetadata, + private val liveUpdateInterval: Duration, +) : ModifiableDirectory { + private val modifiableLiveContent = object : MutableLiveData<ModifiableDirectory.Content>() { + private val looper = Looper.myLooper() + private val handler = if (looper != null) Handler(looper) else null + private val updateCallback = Runnable { update() } + + override fun onActive() { + super.onActive() + + value = modifiableList() + handler?.postDelayed(updateCallback, liveUpdateInterval.inWholeMilliseconds) + } + + override fun onInactive() { + super.onInactive() + + handler?.removeCallbacks(updateCallback) + } + + private fun update() { + val newValue = modifiableList() + if (value != newValue) postValue(newValue) + handler?.postDelayed(updateCallback, liveUpdateInterval.inWholeMilliseconds) + } + } + + private val liveContent: LiveData<Directory.Content> by lazy { + modifiableLiveContent.map { + Directory.Content( + it.directories, + it.files, + it.links, + ) + } + } + + override fun modifiableOpenDir(name: String): ModifiableDirectory? { + val childMetadata = findChild(name) ?: return null + if (!childMetadata.isDir()) return null + val childTreeUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, childMetadata.documentId) + return DocumentDirectory(contentResolver, childTreeUri, childMetadata, liveUpdateInterval) + } + + override fun modifiableOpenFile(name: String): ModifiableFile? { + val childMetadata = findChild(name) ?: return null + if (childMetadata.isDir()) return null + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, childMetadata.documentId) + return DocumentFile(contentResolver, documentUri, childMetadata) + } + + override fun modifiableOpenLink(name: String): ModifiableLink? { + return null + } + + override fun modifiableList(): ModifiableDirectory.Content { + val list = contentResolver.getAllMetadata( + DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, metadata.documentId), + ) ?: throw IOException("Unable to list dir") + val dirs = mutableListOf<ModifiableDirectory>() + val files = mutableListOf<ModifiableFile>() + val links = mutableListOf<ModifiableLink>() + list.forEach { childMetadata -> + if (childMetadata.isDir()) { + val childTreeUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, childMetadata.documentId) + dirs.add(DocumentDirectory(contentResolver, childTreeUri, childMetadata, liveUpdateInterval)) + } else { + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, childMetadata.documentId) + files.add(DocumentFile(contentResolver, documentUri, childMetadata)) + } + } + return ModifiableDirectory.Content(dirs, files, links) + } + + override fun modifiableLiveList(): LiveData<ModifiableDirectory.Content> { + return modifiableLiveContent + } + + override fun createDirectory(name: String): ModifiableDirectory { + if (!metadata.supportsCreate()) throw IOException("Directory doesn't support creation") + val documentUri = DocumentsContract.createDocument( + contentResolver, + DocumentsContract.buildDocumentUriUsingTree(treeUri, metadata.documentId), + DocumentsContract.Document.MIME_TYPE_DIR, + name, + ) ?: throw IOException("Failed to create") + // TODO: Can we just create metadata to save time? We need documentId and flags tho. + val childMetadata = contentResolver.getMetadata(documentUri) + ?: throw IOException("Document was not actually created") + val childTreeUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, childMetadata.documentId) + return DocumentDirectory(contentResolver, childTreeUri, childMetadata, liveUpdateInterval) + } + + override fun createFile(name: String): ModifiableFile { + if (!metadata.supportsCreate()) throw IOException("Directory doesn't support creation") + // TODO: Handle .tar.gz and such? Is it worth it (will android find them anyway) + val dotIndex = name.lastIndexOf('.') + val mimeType = if (dotIndex > 0) { + MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(name.substring(dotIndex + 1)) + } else { + null + } ?: "application/octet-stream" + return DelayedCreationDocumentFile( + DocumentMetadata( + displayName = name, + documentId = "invalid", + flags = 0, + lastModified = null, + mimeType = mimeType, + size = null, + ) + ) { + val documentUri = DocumentsContract.createDocument( + contentResolver, + DocumentsContract.buildDocumentUriUsingTree(treeUri, metadata.documentId), + mimeType, + name, + ) ?: throw IOException("Failed to create") + // TODO: Can we just create metadata to save time? We need documentId and flags tho. + val childMetadata = contentResolver.getMetadata(documentUri) + ?: throw IOException("Document was not actually created") + DocumentFile(contentResolver, documentUri, childMetadata) + } + } + + override fun createLink(name: String, target: Directory): ModifiableLink { + throw IOException("Symlinks are not supported") + } + + override fun createLink(name: String, target: File): ModifiableLink { + throw IOException("Symlinks are not supported") + } + + override fun createLink(name: String, target: String): ModifiableLink { + throw IOException("Symlinks are not supported") + } + + private fun removeDirectory( + documentUri: Uri, + documentTreeUri: Uri, + documentMetadata: DocumentMetadata, + parentDocumentUri: Uri, + ): Boolean { + contentResolver.getAllMetadata( + documentTreeUri, + ).forEach { childMetadata -> + val childUri = DocumentsContract.buildDocumentUriUsingTree(documentTreeUri, childMetadata.documentId) + if (childMetadata.isDir()) { + val childTreeUri = DocumentsContract.buildChildDocumentsUriUsingTree( + documentTreeUri, + childMetadata.documentId, + ) + if (!removeDirectory(childUri, childTreeUri, childMetadata, documentUri)) return false + } else { + val ret = if (childMetadata.supportsRemove()) { + DocumentsContract.removeDocument( + contentResolver, + childUri, + documentUri + ) + } else if (childMetadata.supportsDelete()) { + DocumentsContract.deleteDocument( + contentResolver, + childUri + ) + } else false + if (!ret) return false + } + } + return if (documentMetadata.supportsRemove()) { + DocumentsContract.removeDocument( + contentResolver, + documentUri, + parentDocumentUri, + ) + } else if (documentMetadata.supportsDelete()) { + DocumentsContract.deleteDocument( + contentResolver, + documentUri + ) + } else false + } + + override fun removeDirectory(name: String): Boolean { + val childMetadata = findChild(name) ?: return false + if (!childMetadata.isDir()) return false + val childUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, childMetadata.documentId) + val childTreeUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, childMetadata.documentId) + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, metadata.documentId) + return removeDirectory(childUri, childTreeUri, childMetadata, parentUri) + } + + override fun removeFile(name: String): Boolean { + val childMetadata = findChild(name) ?: return false + if (childMetadata.isDir()) return false + val documentUri = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + childMetadata.documentId + ) + if (childMetadata.supportsRemove()) { + return DocumentsContract.removeDocument( + contentResolver, + documentUri, + DocumentsContract.buildDocumentUri( + treeUri.authority, + metadata.documentId + ), + ) + } else if (childMetadata.supportsDelete()) { + return DocumentsContract.deleteDocument( + contentResolver, + documentUri, + ) + } else { + return false + } + } + + override fun removeLink(name: String): Boolean { + return false + } + + override val name: String + get() = metadata.displayName + + override fun openDir(name: String): Directory? { + return modifiableOpenDir(name) + } + + override fun openFile(name: String): File? { + return modifiableOpenFile(name) + } + + override fun openLink(name: String): Link? { + return modifiableOpenLink(name) + } + + override fun list(): Directory.Content { + return with(modifiableList()) { + Directory.Content( + directories, + files, + links, + ) + } + } + + override fun liveList(): LiveData<Directory.Content> { + return liveContent + } + + override fun equals(other: Any?) = other is DocumentDirectory && + getRealTreeDocumentId(other.treeUri) == getRealTreeDocumentId(treeUri) + override fun hashCode() = getRealTreeDocumentId(treeUri).hashCode() + override fun toString() = getRealTreeDocumentId(treeUri) ?: "null" + + private fun findChild(displayName: String): DocumentMetadata? { + val queryArgs = Bundle() + queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, displayName) + return contentResolver.getAllMetadata( + DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, metadata.documentId), + queryArgs = queryArgs, + ).singleOrNull() + } + + private companion object { + fun getRealTreeDocumentId(treeUri: Uri): String { + // For some reason, getTreeDocumentId returns the root tree, not + // the tree for the children for ChildDocuments uris. + if (treeUri.lastPathSegment == "children") { + return DocumentsContract.getDocumentId(treeUri) + } + return DocumentsContract.getTreeDocumentId(treeUri) + } + } +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentFile.kt b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentFile.kt new file mode 100644 index 0000000..768b679 --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentFile.kt @@ -0,0 +1,47 @@ +package org.the_jk.cleversync.io.documents + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import org.the_jk.cleversync.io.ModifiableFile +import java.io.FilterOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.time.Instant + +internal class DocumentFile( + private val contentResolver: ContentResolver, + private val documentUri: Uri, + private var metadata: DocumentMetadata, +) : ModifiableFile { + override val name: String + get() = metadata.displayName + override val size: ULong + get() = metadata.size ?: 0UL + override val lastModified: Instant + get() = metadata.lastModified ?: Instant.EPOCH + + override fun read(): InputStream { + return contentResolver.openInputStream(documentUri) ?: throw IOException("Provider crashed") + } + + override fun write(): OutputStream { + val ret = contentResolver.openOutputStream(documentUri, "wt") ?: throw IOException("Provider crashed") + return object : FilterOutputStream(ret) { + override fun close() { + super.close() + + // Update metadata + contentResolver.getMetadata(documentUri)?.let { + metadata = it + } + } + } + } + + override fun equals(other: Any?) = other is DocumentFile && + DocumentsContract.getDocumentId(other.documentUri) == DocumentsContract.getDocumentId(documentUri) + override fun hashCode() = DocumentsContract.getDocumentId(documentUri).hashCode() + override fun toString() = DocumentsContract.getDocumentId(documentUri) ?: "null" +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentMetadata.kt b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentMetadata.kt new file mode 100644 index 0000000..932de58 --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentMetadata.kt @@ -0,0 +1,83 @@ +package org.the_jk.cleversync.io.documents + +import android.content.ContentResolver +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import java.time.Instant + +internal data class DocumentMetadata( + val displayName: String, + val documentId: String, + val flags: Int, + val lastModified: Instant?, + val mimeType: String, + val size: ULong?, +) + +internal fun DocumentMetadata.isDir(): Boolean { + return mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} + +internal fun DocumentMetadata.supportsCreate(): Boolean { + return (flags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE) != 0 +} + +internal fun DocumentMetadata.supportsDelete(): Boolean { + return (flags and DocumentsContract.Document.FLAG_SUPPORTS_DELETE) != 0 +} + +internal fun DocumentMetadata.supportsRemove(): Boolean { + return (flags and DocumentsContract.Document.FLAG_SUPPORTS_REMOVE) != 0 +} + +internal fun ContentResolver.getAllMetadata( + uri: Uri, + queryArgs: Bundle? = null, +): List<DocumentMetadata> { + query( + uri, + arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + ), + queryArgs, + null, + )?.use { cursor -> + val displayNameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val documentIdIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS) + val lastModifiedIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + val mimeTypeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE) + return buildList { + while (cursor.moveToNext()) { + val lastModified = if (cursor.isNull(lastModifiedIndex)) { + null + } else { + Instant.ofEpochMilli(cursor.getLong(lastModifiedIndex)) + } + val size = if (cursor.isNull(sizeIndex)) null else cursor.getLong(sizeIndex) + add( + DocumentMetadata( + cursor.getString(displayNameIndex), + cursor.getString(documentIdIndex), + cursor.getInt(flagsIndex), + lastModified, + cursor.getString(mimeTypeIndex), + size?.toULong(), + ), + ) + } + } + } + return listOf() +} + +internal fun ContentResolver.getMetadata(uri: Uri): DocumentMetadata? { + return getAllMetadata(uri).singleOrNull() +} diff --git a/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentTree.kt b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentTree.kt new file mode 100644 index 0000000..ef1d04a --- /dev/null +++ b/libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentTree.kt @@ -0,0 +1,23 @@ +package org.the_jk.cleversync.io.documents + +import android.content.ContentResolver +import android.content.res.Resources +import android.net.Uri +import android.provider.DocumentsContract +import org.the_jk.cleversync.io.ModifiableTree +import kotlin.time.Duration + +internal class DocumentTree( + private val contentResolver: ContentResolver, + private val treeUri: Uri, + metadata: DocumentMetadata, + liveUpdateInterval: Duration, +) : DocumentDirectory(contentResolver, treeUri, metadata, liveUpdateInterval), ModifiableTree { + override fun description(resources: Resources): CharSequence { + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, + DocumentsContract.getTreeDocumentId(treeUri)) + return DocumentsContract.findDocumentPath(contentResolver, documentUri).toString() + } + + override fun close() = Unit +} diff --git a/libs/documents/src/test/java/org/the_jk/cleversync/documents/DocumentTreeTest.kt b/libs/documents/src/test/java/org/the_jk/cleversync/documents/DocumentTreeTest.kt new file mode 100644 index 0000000..f116f0e --- /dev/null +++ b/libs/documents/src/test/java/org/the_jk/cleversync/documents/DocumentTreeTest.kt @@ -0,0 +1,460 @@ +package org.the_jk.cleversync.documents + +import android.Manifest.permission.MANAGE_DOCUMENTS +import android.content.Context +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor +import android.os.Build +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.android.controller.ContentProviderController +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import org.the_jk.cleversync.TreeAbstractTest +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.TimeUnit + +@Config(manifest=Config.NONE) +@RunWith(RobolectricTestRunner::class) +class DocumentTreeTest : TreeAbstractTest() { + @get:Rule + val folder = TemporaryFolder() + + private lateinit var controller: ContentProviderController<FakeProvider> + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext<Context>() + val contentResolver = context.contentResolver + val providerInfo = ProviderInfo() + providerInfo.authority = "org.the_jk.cleversync.documents.test" + providerInfo.grantUriPermissions = true + providerInfo.exported = true + providerInfo.readPermission = MANAGE_DOCUMENTS + providerInfo.writePermission = MANAGE_DOCUMENTS + controller = Robolectric + .buildContentProvider(FakeProvider::class.java) + + controller.get().setBaseDir(folder.root) + controller.create(providerInfo) + + val treeUri = DocumentsContract.buildTreeDocumentUri(providerInfo.authority, ROOT) + assertThat(DocumentsContract.isTreeUri(treeUri)).isTrue() + + tree = DocumentTreeFactory.modifiableTree( + contentResolver, + treeUri, + ) + } + + @After + fun tearDown() { + controller.shutdown() + } + + override fun idle() { + ShadowLooper.idleMainLooper(10, TimeUnit.SECONDS) + } + + override fun supportSymlinks() = false + + private class FakeProvider : DocumentsProvider() { + private lateinit var baseDir: File + + fun setBaseDir(file: File) { + baseDir = file + } + + override fun onCreate(): Boolean { + return true + } + + override fun queryRoots(projection: Array<String>?): Cursor { + val result = MatrixCursor(resolveRootProjection(projection)) + val row = result.newRow() + row.add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT) + row.add(DocumentsContract.Root.COLUMN_SUMMARY, "") + row.add(DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD) + row.add(DocumentsContract.Root.COLUMN_TITLE, "fake") + row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocIdForFile(baseDir)) + row.add(DocumentsContract.Root.COLUMN_MIME_TYPES, getChildMimeTypes()) + row.add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDir.freeSpace) + row.add(DocumentsContract.Root.COLUMN_ICON, null) + return result + } + + override fun queryDocument( + documentId: String?, + projection: Array<String>? + ): Cursor { + val result = MatrixCursor(resolveDocumentProjection(projection)) + includeFile(result, documentId, null) + return result + } + + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array<String>?, + sortOrder: String? + ): Cursor { + val result = MatrixCursor(resolveDocumentProjection(projection)) + val parent = getFileForDocId(parentDocumentId) + parent.listFiles()?.forEach { file -> + includeFile(result, null, file) + } + return result + } + + override fun openDocument( + documentId: String, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = getFileForDocId(documentId) + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } + + override fun isChildDocument( + parentDocumentId: String, + documentId: String + ): Boolean { + try { + val parentFile = getFileForDocId(parentDocumentId) + var childFile: File? = getFileForDocId(documentId) + // childFile can be any level of descendant of parentFile + // to return true. + do { + if (isChildFile(parentFile, childFile!!)) return true + childFile = childFile.parentFile + } while (childFile != null) + return false + } catch (_: FileNotFoundException) { + } + return false + } + + override fun createDocument( + parentDocumentId: String, + mimeType: String?, + displayName: String, + ): String { + val parent = getFileForDocId(parentDocumentId) + val file = File(parent.path, displayName) + try { + var documentWasCreated = false + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + if (file.mkdir()) { + if (file.setWritable(true) && file.setWritable(true) && file.setExecutable(true)) { + documentWasCreated = true + } + } + } else { + if (file.createNewFile()) { + if (file.setWritable(true) && file.setReadable(true)) { + documentWasCreated = true + } + } + } + + if (!documentWasCreated) { + throw IOException("Failed to create document with name " + + displayName +" and documentId " + parentDocumentId) + } + } catch (e: IOException) { + throw IOException("Failed to create document with name " + + displayName +" and documentId " + parentDocumentId, e) + } + return getDocIdForFile(file) + } + + override fun renameDocument( + documentId: String, + displayName: String? + ): String { + if (displayName == null) { + throw IOException("Failed to rename document, new name is null") + } + + val sourceFile = getFileForDocId(documentId) + val sourceParentFile = sourceFile.parentFile + ?: throw IOException("Failed to rename document. File has no parent.") + val destFile = File(sourceParentFile.path, displayName) + + try { + if (!sourceFile.renameTo(destFile)) { + throw IOException("Failed to rename document. Renamed failed.") + } + } catch (e: Exception) { + throw IOException("Failed to rename document.", e) + } + + return getDocIdForFile(destFile) + } + + override fun deleteDocument(documentId: String) { + val file = getFileForDocId(documentId) + if (!file.delete()) { + throw IOException("Failed to delete document with id $documentId") + } + } + + override fun removeDocument( + documentId: String, + parentDocumentId: String, + ) { + val parent = getFileForDocId(parentDocumentId) + val file = getFileForDocId(documentId) + + val doesFileParentMatch = isChildFile(parent, file) + + if (parent == file || doesFileParentMatch) { + if (!file.delete()) { + throw IOException("Failed to delete document with id $documentId") + } + } else { + throw IOException("Failed to delete document with id $documentId") + } + } + + override fun copyDocument( + sourceDocumentId: String, + targetParentDocumentId: String + ): String { + val parent = getFileForDocId(targetParentDocumentId) + val oldFile = getFileForDocId(sourceDocumentId) + val newFile = File(parent.path, oldFile.name) + + try { + var wasNewFileCreated = false + if (newFile.createNewFile()) { + if (newFile.setWritable(true) && newFile.setReadable(true)) { + wasNewFileCreated = true + } + } + + if (!wasNewFileCreated) { + throw IOException("Failed to copy document " + sourceDocumentId + + ". Could not create new file.") + } + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + // Transfer bytes from in to out + val buf = ByteArray(65536) + var len: Int + while ((inStream.read(buf).also { len = it }) > 0) { + outStream.write(buf, 0, len) + } + } + } + } catch (e: IOException) { + throw IOException("Failed to copy document: $sourceDocumentId", e) + } + return getDocIdForFile(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String, + ): String { + try { + // Copy document, insisting that the parent is correct + val newDocumentId = copyDocument(sourceDocumentId, sourceParentDocumentId, + targetParentDocumentId) + // Remove old document + removeDocument(sourceDocumentId,sourceParentDocumentId) + return newDocumentId + } catch (e: IOException) { + throw IOException("Failed to move document $sourceDocumentId", e) + } + } + + override fun getDocumentType(documentId: String): String { + val file = getFileForDocId(documentId) + return getTypeForFile(file) + } + + private fun copyDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String, + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) { + throw IOException( + "Failed to copy document with id " + + sourceDocumentId + ". Parent is not: " + sourceParentDocumentId + ) + } + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + private fun isChildFile(parentFile: File, childFile: File): Boolean { + return parentFile == childFile.parentFile + } + + private fun getDocIdForFile(file: File): String { + var path = file.absolutePath + + // Start at first char of path under root + val rootPath = baseDir.path + path = if (rootPath.equals(path)) { + "" + } else if (rootPath.endsWith("/")) { + path.substring(rootPath.length) + } else { + path.substring(rootPath.length + 1) + } + + return "$ROOT:$path" + } + + private fun getFileForDocId(docId: String): File { + var target = baseDir + if (docId == ROOT) { + return target + } + val splitIndex = docId.indexOf(':', 1) + if (splitIndex < 0) { + throw FileNotFoundException("Missing root for $docId") + } else { + val path = docId.substring(splitIndex + 1) + target = File(target, path) + if (!target.exists()) { + throw FileNotFoundException("Missing file for $docId at $target") + } + return target + } + } + + private fun includeFile(result: MatrixCursor, inDocId: String?, inFile: File?) { + val (docId, file) = if (inDocId == null) { + getDocIdForFile(inFile!!) to inFile + } else { + inDocId to getFileForDocId(inDocId) + } + + var flags = 0 + + if (file.isDirectory) { + // Add FLAG_DIR_SUPPORTS_CREATE if the file is a writable directory. + if (file.canWrite()) { + flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } + } + if (file.canWrite()) { + // If the file is writable set FLAG_SUPPORTS_WRITE and + // FLAG_SUPPORTS_DELETE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + flags = + flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + flags = + flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE + flags = + flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + flags = + flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + } + } + + val displayName = file.name + val mimeType = getTypeForFile(file) + + val row = result.newRow() + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, docId) + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, displayName) + row.add(DocumentsContract.Document.COLUMN_SIZE, file.length()) + row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType) + row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) + row.add(DocumentsContract.Document.COLUMN_FLAGS, flags) + row.add(DocumentsContract.Document.COLUMN_ICON, null) + } + } + + private companion object { + val DEFAULT_ROOT_PROJECTION = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + + val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + + const val ROOT: String = "root" + + fun resolveRootProjection(projection: Array<String>?): Array<String> { + return projection ?: DEFAULT_ROOT_PROJECTION + } + + fun resolveDocumentProjection(projection: Array<String>?): Array<String> { + return projection ?: DEFAULT_DOCUMENT_PROJECTION + } + + fun getTypeForFile(file: File): String { + return if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + getTypeForName(file.name) + } + } + + fun getTypeForName(name: String): String { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) { + return mime + } + } + return "application/octet-stream" + } + + fun getChildMimeTypes(): String { + val mimeTypes = setOf( + "image/*", + "text/*", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + DocumentsContract.Document.MIME_TYPE_DIR, + "application/octet-stream", + ) + return mimeTypes.joinToString("\n") + } + } +} diff --git a/libs/documents/src/test/resources/robolectric.properties b/libs/documents/src/test/resources/robolectric.properties new file mode 120000 index 0000000..c0de003 --- /dev/null +++ b/libs/documents/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +../../../../../app/src/test/resources/robolectric.properties
\ No newline at end of file diff --git a/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt b/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt index 302e809..ba65108 100644 --- a/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt +++ b/libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt @@ -22,10 +22,13 @@ abstract class TreeAbstractTest { @Test open fun emptyLive() { - val content = tree.liveList().safeValue() - assertThat(content?.directories).isEmpty() - assertThat(content?.files).isEmpty() - assertThat(content?.links).isEmpty() + val liveContent = tree.liveList() + onMain { + val content = liveContent.safeValue() + assertThat(content?.directories).isEmpty() + assertThat(content?.files).isEmpty() + assertThat(content?.links).isEmpty() + } } @Test @@ -45,15 +48,20 @@ abstract class TreeAbstractTest { @Test(timeout = 10000) open fun observeCreateDirectory() { val content = tree.liveList() + // Only accessed on main thread var dir: Directory? = null - content.observeForever { - if (it.directories.size == 1) dir = it.directories[0] + onMain { + content.observeForever { + if (it.directories.size == 1) dir = it.directories[0] + } } tree.createDirectory("foo") - while (dir == null) { - idle() + onMain { + while (dir == null) { + idle() + } + assertThat(dir?.name).isEqualTo("foo") } - assertThat(dir?.name).isEqualTo("foo") } @Test @@ -100,13 +108,18 @@ abstract class TreeAbstractTest { open fun removeDirLive() { tree.createDirectory("foo") val content = tree.liveList() + // Only accessed on main var done = false - content.observeForever { - if (it.directories.isEmpty()) done = true + onMain { + content.observeForever { + if (it.directories.isEmpty()) done = true + } } assertThat(tree.removeDirectory("foo")).isTrue() - while (!done) { - idle() + onMain { + while (!done) { + idle() + } } } @@ -166,16 +179,23 @@ abstract class TreeAbstractTest { Assume.assumeTrue(supportSymlinks()) val content = tree.liveList() + // Only accessed on main var link: Link? = null - content.observeForever { - if (it.links.size == 1) link = it.links[0] + onMain { + content.observeForever { + if (it.links.size == 1) link = it.links[0] + } } val dir = tree.createDirectory("dir") tree.createLink("link", "dir") - while (link == null) { - idle() + onMain { + while (link == null) { + idle() + } + assertThat((link?.resolve() as Link.DirectoryTarget).directory).isEqualTo( + dir + ) } - assertThat((link?.resolve() as Link.DirectoryTarget).directory).isEqualTo(dir) } @Test @@ -336,4 +356,8 @@ abstract class TreeAbstractTest { protected abstract fun supportSymlinks(): Boolean protected abstract fun idle() + + protected open fun onMain(block: () -> Unit) { + block.invoke() + } } |
