summaryrefslogtreecommitdiff
path: root/libs
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2024-09-10 23:46:21 +0200
committerJoel Klinghed <the_jk@spawned.biz>2024-09-10 23:50:27 +0200
commit994672608db65a68b3ba3db8fa37bb613de89c20 (patch)
tree4873d7177a7949f7e1501e9494e2897e2da35d03 /libs
parent3e1b734cd804dbdb8bff8bbdc944a0fd141bed75 (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')
-rw-r--r--libs/documents/.gitignore1
-rw-r--r--libs/documents/build.gradle.kts36
-rw-r--r--libs/documents/consumer-rules.pro0
-rw-r--r--libs/documents/proguard-rules.pro21
-rw-r--r--libs/documents/src/androidTest/AndroidManifest.xml6
-rw-r--r--libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/DocumentTreeAndroidTest.kt216
-rw-r--r--libs/documents/src/androidTest/java/org/the_jk/cleversync/documents/test/TestActivity.kt26
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/documents/DocumentTreeFactory.kt33
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DelayedCreationDocumentFile.kt36
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentDirectory.kt302
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentFile.kt47
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentMetadata.kt83
-rw-r--r--libs/documents/src/main/java/org/the_jk/cleversync/io/documents/DocumentTree.kt23
-rw-r--r--libs/documents/src/test/java/org/the_jk/cleversync/documents/DocumentTreeTest.kt460
l---------libs/documents/src/test/resources/robolectric.properties1
-rw-r--r--libs/test-utils/src/main/java/org/the_jk/cleversync/TreeAbstractTest.kt60
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()
+ }
}