diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0cd48d..5951b8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,10 +5,20 @@ on: pull_request: types: [ opened, synchronize, reopened ] paths: + - ".github/workflows/build.yml" + - "*.gradle" + - "gradle.properties" + - "gradle/**" + - "app/**" - "dfc/**" push: branches: [ master ] paths: + - ".github/workflows/build.yml" + - "*.gradle" + - "gradle.properties" + - "gradle/**" + - "app/**" - "dfc/**" jobs: @@ -25,5 +35,5 @@ jobs: java-version: "17" cache: "gradle" - - name: Build DFC - run: ./gradlew :dfc:assemble + - name: Build + run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug diff --git a/.gitignore b/.gitignore index 393f62c..7bfad29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ build .kotlin .gradle local.properties + +.claude/ +.research/ diff --git a/README.md b/README.md index b45e965..af0b473 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ do not keep paying for the same queries again and again. - Common `DocumentFile`-style methods and getters. - Faster directory listing and metadata access. - Custom projections for lighter queries. +- Query-based child listing for filtering, sorting, paging, and projection. - Convenience APIs like `count()`, `copyTo(destination)`, and `copyFrom(source)`. ## Installation @@ -67,8 +68,45 @@ Other entry points: - `DocumentFileCompat.fromSingleUri(context, uri)` - `DocumentFileCompat.fromFile(context, file)` -Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`, and -`listFiles(projection)` are available when you need them. +Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`, +`listFiles(projection)`, and `listFiles(vararg queries)` are available when you need them. + +### Query Child Documents + +For tree-backed SAF directories, `Query` lets you pass projection, sort, filter, limit, and offset +hints without dropping down to raw `ContentResolver` code. + +```kotlin +import android.provider.DocumentsContract.Document +import com.lazygeniouz.dfc.file.Query + +val recentFiles = directory.listFiles( + Query.filesOnly(), + Query.orderByDesc(Document.COLUMN_LAST_MODIFIED), + Query.limit(100), + Query.select( + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + ), +) +``` + +On API 21-25, only `Query.select(...)`, `Query.orderByAsc(...)`, and `Query.orderByDesc(...)` +are forwarded. On API 26+, filters, `Query.limit(...)`, `Query.offset(...)`, and +`Query.rawSelection(...)` are also forwarded. + +Providers may still ignore supported query arguments. `DocumentFileCompat` forwards them, but the +underlying provider decides what actually gets honored. + +Some quick tips: + +- Use `Query.select(...)` to fetch only the columns you need. +- Use `Query.limit(...)` for previews, search results, and paged lists. +- Prefer exact filters like `filesOnly()`, `mimeType(...)`, and `nameEquals(...)` over broad + `nameContains(...)` queries. +- Avoid repeated `listFiles(...)` calls for the same directory; reuse the returned list when you + can. +- Treat queries as fast-path provider hints, not guaranteed filtering across every provider. ## Performance diff --git a/app/build.gradle b/app/build.gradle index 4409bfc..35b95ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,14 +3,14 @@ plugins { } android { - compileSdk = 36 + compileSdk = 37 namespace = "com.lazygeniouz.filecompat.example" defaultConfig { applicationId = "com.lazygeniouz.filecompat.example" minSdk = 23 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "1.0" @@ -36,10 +36,10 @@ android { dependencies { implementation project(":dfc") - // implementation "com.lazygeniouz:dfc:1.3" + // implementation "com.lazygeniouz:dfc:" implementation "androidx.appcompat:appcompat:1.7.1" - implementation "androidx.activity:activity-ktx:1.12.3" + implementation "androidx.activity:activity-ktx:1.13.0" implementation "androidx.documentfile:documentfile:1.1.0" - implementation "com.google.android.material:material:1.13.0" + implementation "com.google.android.material:material:1.14.0" } \ No newline at end of file diff --git a/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt b/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt index d13613b..242b609 100644 --- a/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt +++ b/app/src/main/java/com/lazygeniouz/filecompat/example/performance/ProjectionPerformance.kt @@ -18,10 +18,10 @@ object ProjectionPerformance { // Test 1: Full projection (default) results += testFullProjection(context, uri) + "\n\n" - // Test 2: Minimal projection (ID + Name only) + // Test 2: Minimal requested projection. results += testMinimalProjection(context, uri) + "\n\n" - // Test 3: ID + Name + Size + // Test 3: Partial requested projection. results += testPartialProjection(context, uri) + "\n\n" results += "=".repeat(48).plus("\n\n") @@ -52,7 +52,7 @@ object ProjectionPerformance { var fileCount = 0 measureTimeSeconds { val documentFile = DocumentFileCompat.fromTreeUri(context, uri) - // Only fetch ID and Name + // MIME type is still added internally so child files keep the right behavior. val minimalProjection = arrayOf( Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME @@ -65,7 +65,7 @@ object ProjectionPerformance { val name = file.name // Should work } }.also { time -> - return "Minimal Projection (ID + Name):\n" + + return "Minimal Projection (ID + Name; MIME added internally):\n" + "Files: $fileCount\n" + "Time: ${time}s" } @@ -76,7 +76,7 @@ object ProjectionPerformance { var totalSize = 0L measureTimeSeconds { val documentFile = DocumentFileCompat.fromTreeUri(context, uri) - // Fetch ID, Name, and Size + // MIME type is still added internally so child files keep the right behavior. val partialProjection = arrayOf( Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, @@ -91,7 +91,7 @@ object ProjectionPerformance { } }.also { time -> val sizeMb = Performance.getSizeInMb(totalSize) - return "Partial Projection (ID + Name + Size):\n" + + return "Partial Projection (ID + Name + Size; MIME added internally):\n" + "Files: $fileCount\n" + "Total Size: $sizeMb\n" + "Time: ${time}s" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 7a537b1..a57785f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,21 +1,28 @@ - + android:layout_height="0dp" + android:layout_weight="1" + android:fillViewport="true"> + + + @@ -23,6 +30,7 @@ android:id="@+id/buttonDir" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" android:layout_marginVertical="12dp" android:paddingVertical="12dp" android:text="@string/select_directory" /> @@ -31,6 +39,7 @@ android:id="@+id/buttonFile" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" android:layout_marginVertical="12dp" android:paddingVertical="12dp" android:text="@string/select_a_file" /> @@ -39,6 +48,7 @@ android:id="@+id/buttonProjections" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" android:layout_marginVertical="12dp" android:paddingVertical="12dp" android:text="@string/test_custom_projections" /> diff --git a/build.gradle b/build.gradle index e078c4c..6f6e741 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ allprojects { } plugins.withId("com.vanniktech.maven.publish.base") { - version = "1.6" + version = "1.7" group = "com.lazygeniouz" mavenPublishing { @@ -36,6 +36,13 @@ allprojects { "release" )) } + + tasks.withType(org.gradle.jvm.tasks.Jar).matching { task -> + task.name == "dokkaJavadocJar" + }.configureEach { task -> + task.dependsOn(tasks.named("dokkaGenerateHtml")) + task.from(layout.buildDirectory.dir("dokka/html")) + } } } diff --git a/dfc/build.gradle b/dfc/build.gradle index 6aca964..3f7bc38 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -24,4 +24,9 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.16.1" } \ No newline at end of file diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt index 85d189a..0c39acb 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.resolver.ResolverCompat @@ -39,6 +40,15 @@ internal class DocumentController( else ResolverCompat.listFiles(context, fileCompat, projection) } + /** + * List child documents using provider-specific query arguments. + */ + internal fun listFiles(vararg queries: Query): List { + return if (!isDirectory()) { + throw UnsupportedOperationException("Selected document is not a Directory.") + } else ResolverCompat.listFiles(context, fileCompat, *queries) + } + /** * This will return the children count in the directory. * @@ -115,7 +125,6 @@ internal class DocumentController( if (Document.MIME_TYPE_DIR == fileCompat.documentMimeType && fileCompat.documentFlags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE != 0 ) return true - else if (fileCompat.documentMimeType.isNotEmpty() && fileCompat.documentFlags and Document.FLAG_SUPPORTS_WRITE != 0 ) return true diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt index 3537242..4bf7f4a 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -2,6 +2,7 @@ package com.lazygeniouz.dfc.file import android.content.ContentResolver import android.content.Context +import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document @@ -9,6 +10,7 @@ import com.lazygeniouz.dfc.controller.DocumentController import com.lazygeniouz.dfc.file.internals.RawDocumentFileCompat import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat +import com.lazygeniouz.dfc.logger.ErrorLogger import java.io.File /** @@ -73,6 +75,18 @@ abstract class DocumentFileCompat( */ abstract fun listFiles(projection: Array): List + /** + * List child documents using [Query] clauses. + * + * This is only supported for tree-backed directories. + */ + // Open instead of abstract so existing external subclasses keep source compatibility. + open fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException( + "Queries are only supported for DocumentsProvider-backed tree URIs." + ) + } + /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. * @@ -253,5 +267,41 @@ abstract class DocumentFileCompat( val paths = uri.pathSegments return paths.size >= 2 && "tree" == paths[0] } + + @JvmSynthetic + internal fun makeFromCursor( + cursor: Cursor, + errorMessage: String, + buildFile: ( + documentName: String, + documentSize: Long, + documentLastModified: Long, + documentMimeType: String, + documentFlags: Int, + ) -> T, + ): T? { + return try { + cursor.use { + if (!it.moveToFirst()) return@use null + + val documentName: String = it.getString(1) + val documentSize: Long = it.getLong(2) + val documentLastModified: Long = it.getLong(3) + val documentMimeType: String = it.getString(4) + val documentFlags: Int = it.getLong(5).toInt() + + buildFile( + documentName, + documentSize, + documentLastModified, + documentMimeType, + documentFlags, + ) + } + } catch (exception: Exception) { + ErrorLogger.logError(errorMessage, exception) + null + } + } } } \ No newline at end of file diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt new file mode 100644 index 0000000..4313142 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -0,0 +1,548 @@ +package com.lazygeniouz.dfc.file + +import android.provider.DocumentsContract.Document +import com.lazygeniouz.dfc.file.Query.Companion.limit +import com.lazygeniouz.dfc.file.Query.Companion.offset +import com.lazygeniouz.dfc.file.Query.Companion.orderByAsc +import com.lazygeniouz.dfc.file.Query.Companion.orderByDesc +import com.lazygeniouz.dfc.file.Query.Companion.rawSelection + +/** + * Query clauses for [DocumentFileCompat.listFiles]. + * + * For tree-backed SAF directories: + * + * - API 21-25: only [select], [orderByAsc], and [orderByDesc] are forwarded. + * - API 26+: filter queries, [limit], [offset], and [rawSelection] are also forwarded. + * + * Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses. + * Filter values must be null, String, Number, or Boolean. + */ +sealed class Query private constructor() { + + companion object { + + /** + * Fetch the given columns, plus columns required internally to build results. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun select(vararg columns: String): Query { + require(columns.isNotEmpty()) { "select requires at least one column" } + columns.forEach { column -> requireAndroidColumnName(column, "column") } + return QuerySpec( + projectionColumnsValue = columns.toList(), + description = "select", + ) + } + + /** + * Sort ascending by the given column. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun orderByAsc(column: String): Query { + return sortQuery(column, descending = false) + } + + /** + * Sort descending by the given column. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun orderByDesc(column: String): Query { + return sortQuery(column, descending = true) + } + + /** + * Limit the number of returned child documents. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun limit(count: Int): Query { + require(count >= 0) { "limit must be >= 0" } + return QuerySpec( + limitCountValue = count, + description = "limit($count)", + ) + } + + /** + * Skip the first [count] child documents. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun offset(count: Int): Query { + require(count >= 0) { "offset must be >= 0" } + return QuerySpec( + offsetCountValue = count, + description = "offset($count)", + ) + } + + /** + * Attribute equals [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun equal(attribute: String, value: Any?): Query { + return if (value == null) isNull(attribute) + else selection(attribute, "equal($attribute)") { column -> + compiledSelection("($column = ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute does not equal [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun notEqual(attribute: String, value: Any?): Query { + return if (value == null) isNotNull(attribute) + else selection(attribute, "notEqual($attribute)") { column -> + compiledSelection("($column != ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute equals one of [values]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun `in`(attribute: String, vararg values: Any?): Query { + require(values.isNotEmpty()) { "in requires at least one value" } + return selection(attribute, "in($attribute)") { column -> + buildInSelection(column, values.toList()) + } + } + + /** + * Attribute does not equal any of [values]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun notIn(attribute: String, vararg values: Any?): Query { + require(values.isNotEmpty()) { "notIn requires at least one value" } + return selection(attribute, "notIn($attribute)") { column -> + buildNotInSelection(column, values.toList()) + } + } + + /** + * Attribute is greater than [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun greaterThan(attribute: String, value: Any): Query { + return selection(attribute, "greaterThan($attribute)") { column -> + compiledSelection("($column > ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is greater than or equal to [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun greaterThanOrEqual(attribute: String, value: Any): Query { + return selection(attribute, "greaterThanOrEqual($attribute)") { column -> + compiledSelection("($column >= ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is less than [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lessThan(attribute: String, value: Any): Query { + return selection(attribute, "lessThan($attribute)") { column -> + compiledSelection("($column < ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is less than or equal to [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lessThanOrEqual(attribute: String, value: Any): Query { + return selection(attribute, "lessThanOrEqual($attribute)") { column -> + compiledSelection("($column <= ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is between [start] and [endInclusive]. + * + * Numeric ranges require [start] <= [endInclusive]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun between(attribute: String, start: Any, endInclusive: Any): Query { + requireBetweenOrder(start, endInclusive) + return selection(attribute, "between($attribute)") { column -> + compiledSelection( + "($column BETWEEN ? AND ?)", + listOf(start.toSqlArg(), endInclusive.toSqlArg()), + ) + } + } + + /** + * Attribute is null. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun isNull(attribute: String): Query { + return selection(attribute, "isNull($attribute)") { column -> + compiledSelection("($column IS NULL)", emptyList()) + } + } + + /** + * Attribute is not null. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun isNotNull(attribute: String): Query { + return selection(attribute, "isNotNull($attribute)") { column -> + compiledSelection("($column IS NOT NULL)", emptyList()) + } + } + + /** + * Attribute matches the SQL LIKE [pattern]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun like(attribute: String, pattern: String): Query { + return selection(attribute, "like($attribute)") { column -> + compiledSelection("($column LIKE ? ESCAPE '\\')", listOf(pattern)) + } + } + + /** + * Attribute does not match the SQL LIKE [pattern]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun notLike(attribute: String, pattern: String): Query { + return selection(attribute, "notLike($attribute)") { column -> + compiledSelection("($column NOT LIKE ? ESCAPE '\\')", listOf(pattern)) + } + } + + /** + * Pass a raw SQL-style selection expression. + * + * The selection is grouped and forwarded as a SQL selection clause. + * Callers must keep column names trusted. + * Use this for provider-specific expressions or qualified column references. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun rawSelection(selection: String, vararg args: String): Query { + require(selection.isNotBlank()) { "selection must not be blank" } + return QuerySpec( + rawSelectionPartValue = compiledSelection("($selection)", args.toList()), + description = "rawSelection", + ) + } + + /** + * Exclude directories. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun filesOnly(): Query { + return notEqual(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Include only directories. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun directoriesOnly(): Query { + return equal(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Name equals [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun nameEquals(value: String): Query { + return equal(Document.COLUMN_DISPLAY_NAME, value) + } + + /** + * Name contains [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun nameContains(value: String): Query { + return like( + Document.COLUMN_DISPLAY_NAME, + "%${escapeLikePattern(value)}%", + ) + } + + /** + * MIME type equals [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun mimeType(value: String): Query { + return equal(Document.COLUMN_MIME_TYPE, value) + } + + /** + * MIME type equals one of [values]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun mimeTypeIn(vararg values: String): Query { + return `in`(Document.COLUMN_MIME_TYPE, *values) + } + + /** + * Size is greater than [bytes]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun sizeGreaterThan(bytes: Long): Query { + return greaterThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Size is less than [bytes]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun sizeLessThan(bytes: Long): Query { + return lessThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Last modified time is after [timestampMillis]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lastModifiedAfter(timestampMillis: Long): Query { + return greaterThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + /** + * Last modified time is before [timestampMillis]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lastModifiedBefore(timestampMillis: Long): Query { + return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + @JvmSynthetic + internal fun projectionColumns(query: Query): List? { + return query.asSpec().projectionColumnsValue + } + + @JvmSynthetic + internal fun sortClause(query: Query): String? { + return query.asSpec().sortClauseValue + } + + @JvmSynthetic + internal fun limitCount(query: Query): Int? { + return query.asSpec().limitCountValue + } + + @JvmSynthetic + internal fun offsetCount(query: Query): Int? { + return query.asSpec().offsetCountValue + } + + @JvmSynthetic + internal fun selectionPart(query: Query): Pair>? { + return query.asSpec().selectionPartValue + } + + @JvmSynthetic + internal fun rawSelectionPart(query: Query): Pair>? { + return query.asSpec().rawSelectionPartValue + } + + @JvmSynthetic + internal fun describe(query: Query): String { + return query.asSpec().description + } + + private fun Query.asSpec(): QuerySpec { + return this as QuerySpec + } + + private fun sortQuery(column: String, descending: Boolean): Query { + requireAndroidColumnName(column, "column") + return QuerySpec( + sortClauseValue = "$column ${if (descending) "DESC" else "ASC"}", + description = if (descending) "orderByDesc($column)" else "orderByAsc($column)", + ) + } + + private fun selection( + attribute: String, + description: String, + build: (String) -> Pair>, + ): Query { + requireAndroidColumnName(attribute, "attribute") + return QuerySpec( + selectionPartValue = build(attribute), + description = description, + ) + } + + private fun escapeLikePattern(value: String): String { + return value + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + } + + private val androidColumnNamePattern = Regex("[A-Za-z_][A-Za-z0-9_]*") + + private fun requireAndroidColumnName(identifier: String, label: String) { + require(androidColumnNamePattern.matches(identifier)) { + "$label must be a simple Android contract column name." + } + } + + private fun buildInSelection( + attribute: String, + values: List, + ): Pair> { + val nonNullValues = values.filterNotNull() + val hasNull = values.any { it == null } + + return when { + nonNullValues.isEmpty() && hasNull -> compiledSelection( + "($attribute IS NULL)", + emptyList(), + ) + + hasNull -> compiledSelection( + "(($attribute IN (${nonNullValues.joinToString(",") { "?" }})) OR ($attribute IS NULL))", + nonNullValues.map { it.toSqlArg() }, + ) + + else -> compiledSelection( + "($attribute IN (${nonNullValues.joinToString(",") { "?" }}))", + nonNullValues.map { it.toSqlArg() }, + ) + } + } + + private fun buildNotInSelection( + attribute: String, + values: List, + ): Pair> { + val nonNullValues = values.filterNotNull() + val hasNull = values.any { it == null } + + return when { + nonNullValues.isEmpty() && hasNull -> compiledSelection( + "($attribute IS NOT NULL)", + emptyList(), + ) + + hasNull -> compiledSelection( + "(($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }})) AND ($attribute IS NOT NULL))", + nonNullValues.map { it.toSqlArg() }, + ) + + else -> compiledSelection( + "($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }}))", + nonNullValues.map { it.toSqlArg() }, + ) + } + } + + private fun compiledSelection( + selection: String, + args: List, + ): Pair> { + return selection to args + } + + private fun Any?.toSqlArg(): String { + return when (this) { + null -> "null" + is String -> this + is Boolean -> if (this) "1" else "0" + is Float -> { + require(isFinite()) { "query value must be finite." } + toString() + } + + is Double -> { + require(isFinite()) { "query value must be finite." } + toString() + } + + is Number -> toString() + else -> throw IllegalArgumentException( + "query value must be null, String, Number, or Boolean." + ) + } + } + + private fun requireBetweenOrder(start: Any, endInclusive: Any) { + if (start !is Number || endInclusive !is Number) return + + val startValue = start.toSqlArg().toBigDecimal() + val endValue = endInclusive.toSqlArg().toBigDecimal() + require(startValue <= endValue) { + "between start must be <= endInclusive." + } + } + } + + private class QuerySpec( + val projectionColumnsValue: List? = null, + val sortClauseValue: String? = null, + val limitCountValue: Int? = null, + val offsetCountValue: Int? = null, + val selectionPartValue: Pair>? = null, + val rawSelectionPartValue: Pair>? = null, + val description: String, + ) : Query() +} diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt index bb20f65..b47ca31 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt @@ -106,7 +106,8 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : /** * Returns list of files using File API. * - * Note: [projection] is ignored as the File API doesn't support it. + * [projection] is ignored here because it only affects provider column fetching, + * not which child files are returned. */ override fun listFiles(projection: Array): List { return listFiles() diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt index c2552c7..c9155f7 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt @@ -113,18 +113,15 @@ internal class SingleDocumentFileCompat( if (!DocumentsContract.isDocumentUri(context, self)) return null ResolverCompat.getCursor(context, self, ResolverCompat.fullProjection) - ?.use { cursor -> - if (cursor.moveToFirst()) { - val documentName: String = cursor.getString(1) - val documentSize: Long = cursor.getLong(2) - val documentLastModified: Long = cursor.getLong(3) - val documentMimeType: String = cursor.getString(4) - val documentFlags: Int = cursor.getLong(5).toInt() - - return SingleDocumentFileCompat( + ?.let { cursor -> + return DocumentFileCompat.makeFromCursor( + cursor, + "Exception while building a single document file", + ) { name, size, lastModified, mimeType, flags -> + SingleDocumentFileCompat( context, self, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags + name, size, + lastModified, mimeType, flags, ) } } diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt index b635b62..666a3ba 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.MIME_TYPE_DIR import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -61,6 +62,10 @@ internal class TreeDocumentFileCompat( return fileController.listFiles() } + override fun listFiles(vararg queries: Query): List { + return fileController.listFiles(*queries) + } + /** * This will return the children count in the directory. * @@ -124,18 +129,15 @@ internal class TreeDocumentFileCompat( } else uri ResolverCompat.getCursor(context, treeUri, ResolverCompat.fullProjection) - ?.use { cursor -> - if (cursor.moveToFirst()) { - val documentName: String = cursor.getString(1) - val documentSize: Long = cursor.getLong(2) - val documentLastModified: Long = cursor.getLong(3) - val documentMimeType: String = cursor.getString(4) - val documentFlags: Int = cursor.getLong(5).toInt() - - return TreeDocumentFileCompat( + ?.let { cursor -> + return DocumentFileCompat.makeFromCursor( + cursor, + "Exception while building a tree document file", + ) { name, size, lastModified, mimeType, flags -> + TreeDocumentFileCompat( context, treeUri, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags + name, size, + lastModified, mimeType, flags, ) } } diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt index dfda655..7bca777 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt @@ -13,4 +13,11 @@ object ErrorLogger { internal fun logError(message: String, throwable: Throwable?) { Log.e("DocumentFileCompat", "$message: ${throwable?.message}") } + + /** + * Log warning to logcat for non-fatal behavior differences. + */ + internal fun logWarning(message: String) { + Log.w("DocumentFileCompat", message) + } } \ No newline at end of file diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt index def6858..1b03d1c 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -4,9 +4,12 @@ import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.net.Uri +import android.os.Build +import android.os.Bundle import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat import com.lazygeniouz.dfc.logger.ErrorLogger @@ -93,11 +96,18 @@ internal object ResolverCompat { */ internal fun count(context: Context, uri: Uri): Int { val childrenUri = createChildrenUri(uri) - return getCursor( - context, - childrenUri, - iconProjection - )?.use { cursor -> return cursor.count } ?: 0 + val cursor = getCursor(context, childrenUri, iconProjection) ?: return 0 + return count(cursor) + } + + @JvmSynthetic + internal fun count(cursor: Cursor): Int { + return try { + cursor.use { it.count } + } catch (exception: Exception) { + ErrorLogger.logError("Exception while counting child documents", exception) + 0 + } } /** @@ -121,98 +131,270 @@ internal object ResolverCompat { context: Context, file: DocumentFileCompat, projection: Array = fullProjection, + ): List { + val effectiveProjection = projection.ifEmpty { fullProjection } + return listFiles(context, file, Query.select(*effectiveProjection)) + } + + /** + * Queries the ContentResolver using provider-level query arguments and builds + * a list of [DocumentFileCompat]. + */ + internal fun listFiles( + context: Context, + file: DocumentFileCompat, + vararg queries: Query, ): List { val uri = file.uri val childrenUri = createChildrenUri(uri) - val listOfDocuments = arrayListOf() + val projectionQueries = queries.mapNotNull { query -> Query.projectionColumns(query) } + val projection = LinkedHashSet().apply { + // Required internally to build child Uris and preserve child document behavior. + add(Document.COLUMN_DOCUMENT_ID) + add(Document.COLUMN_MIME_TYPE) - val finalProjection = arrayOf( - Document.COLUMN_DOCUMENT_ID, /* identifier */ - Document.COLUMN_MIME_TYPE, /* for supporting rename via `isDirectory` check */ - *projection - ).distinct().toTypedArray() - - val cursor = getCursor(context, childrenUri, finalProjection) ?: return emptyList() - - cursor.use { - val itemCount = cursor.count - /** - * Pre-sizing the list to avoid resizing overhead. - * This is especially beneficial for directories with a large number of files. - * - * Memory comparison for 8192 files: - * 1. With pre-sizing: 3.10 MB - * 2. Without pre-sizing: 9.60 MB - */ - if (itemCount > 10) listOfDocuments.ensureCapacity(itemCount) - - // Resolve column indices dynamically - val idIndex = cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID) - - val nameIndex = cursor.getColumnIndex(Document.COLUMN_DISPLAY_NAME) - val sizeIndex = cursor.getColumnIndex(Document.COLUMN_SIZE) - val modifiedIndex = cursor.getColumnIndex(Document.COLUMN_LAST_MODIFIED) - val mimeIndex = cursor.getColumnIndex(Document.COLUMN_MIME_TYPE) - val flagsIndex = cursor.getColumnIndex(Document.COLUMN_FLAGS) - - while (cursor.moveToNext()) { - val documentId = cursor.getString(idIndex) ?: continue - val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) - - val documentName = getStringOrDefault(cursor, nameIndex) - val documentSize = getLongOrDefault(cursor, sizeIndex) - val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L) - val documentMimeType = getStringOrDefault(cursor, mimeIndex) + if (projectionQueries.isEmpty()) { + addAll(fullProjection) + } else { + projectionQueries.forEach { addAll(it) } + } + }.toTypedArray() - /** - * Default flags to 0 (no capabilities) when not included. - * Using `-1` here would make bitwise checks behave as "all flags set". - */ - val documentFlags = getLongOrDefault(cursor, flagsIndex, 0L).toInt() - - /* return correct document type */ - val childFile: DocumentFileCompat = - if (documentMimeType == Document.MIME_TYPE_DIR) { - TreeDocumentFileCompat( - context, documentUri, documentName, - documentSize, lastModifiedTime, - documentMimeType, documentFlags - ) - } else { - SingleDocumentFileCompat( - context, documentUri, documentName, - documentSize, lastModifiedTime, - documentMimeType, documentFlags - ) - } - childFile.parentFile = file - listOfDocuments.add(childFile) + val ignoredQueries = mutableListOf() + val selectionParts = mutableListOf() + val selectionArgs = mutableListOf() + val sortClauses = mutableListOf() + + var limit: Int? = null + var offset: Int? = null + + queries.forEach { query -> + if (Query.projectionColumns(query) != null) return@forEach + + val sortClause = Query.sortClause(query) + if (sortClause != null) { + sortClauses += sortClause + return@forEach + } + + val limitCount = Query.limitCount(query) + if (limitCount != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) limit = limitCount + else ignoredQueries += query + return@forEach + } + + val offsetCount = Query.offsetCount(query) + if (offsetCount != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = offsetCount + else ignoredQueries += query + return@forEach + } + + val selectionPart = Query.selectionPart(query) + if (selectionPart != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += selectionPart.first + selectionArgs += selectionPart.second + } else { + ignoredQueries += query + } + return@forEach + } + + val rawSelectionPart = Query.rawSelectionPart(query) + if (rawSelectionPart != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += rawSelectionPart.first + selectionArgs += rawSelectionPart.second + } else { + ignoredQueries += query + } + } + } + + logIgnoredQueriesIfNeeded(ignoredQueries) + + val sortOrder = sortClauses.takeIf { it.isNotEmpty() }?.joinToString(", ") + val selection = selectionParts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") + val queryArgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + (limit != null || offset != null || selection != null || sortOrder != null) + ) { + Bundle().apply { + if (selection != null) { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + putStringArray( + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, + selectionArgs.toTypedArray(), + ) + } + + if (sortOrder != null) { + putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder) + } + + if (limit != null) putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + if (offset != null) putInt(ContentResolver.QUERY_ARG_OFFSET, offset) } + } else { + null } - return listOfDocuments + val cursor = getCursor( + context, + childrenUri, + projection, + queryArgs, + selection, + selectionArgs.takeIf { it.isNotEmpty() }?.toTypedArray(), + sortOrder, + ) ?: return emptyList() + + return buildDocumentList(context, file, uri, cursor) } /** * Get [Cursor] from [ContentResolver.query] with given [projection] on a given [uri]. */ - fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { + internal fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { + return getCursor(context, uri, projection, null, null, null, null) + } + + /** + * Get [Cursor] from [ContentResolver.query] using compiled provider query arguments. + */ + internal fun getCursor( + context: Context, + uri: Uri, + projection: Array, + queryArgs: Bundle?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { return try { - context.contentResolver.query( - uri, projection, null, null, null - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (queryArgs != null) { + context.contentResolver.query(uri, projection, queryArgs, null) + } else { + context.contentResolver.query(uri, projection, null, null, null) + } + } else { + // Pre-O child document queries only have the legacy selection/sortOrder path. + // Our compiler ignores unsupported filters there, but still preserves sorting. + context.contentResolver.query( + uri, + projection, + selection, + selectionArgs, + sortOrder, + ) + } } catch (exception: Exception) { - /** - * This exception can occur in scenarios such as - - * - * - The Uri became invalid due to external changes (e.g., permissions revoked, storage unmounted, etc.). - * - The file or directory represented by this Uri was probably deleted or became `inaccessible` after the Uri was obtained but before this operation was performed. - */ ErrorLogger.logError("Exception while building the Cursor", exception) null } } + private fun logIgnoredQueriesIfNeeded(ignoredQueries: List) { + if (ignoredQueries.isEmpty()) return + + ErrorLogger.logWarning( + buildString { + append("Ignored unsupported queries on API ") + append(Build.VERSION.SDK_INT) + append(": ") + append(ignoredQueries.joinToString { Query.describe(it) }) + append(". SAF child-document filtering, limit, and offset require API 26+.") + } + ) + } + + @JvmSynthetic + internal fun buildDocumentList( + context: Context, + file: DocumentFileCompat, + treeUri: Uri, + cursor: Cursor, + ): List { + val listOfDocuments = arrayListOf() + + return try { + cursor.use { + val itemCount = cursor.count + /** + * Pre-sizing the list to avoid resizing overhead. + * This is especially beneficial for directories with a large number of files. + * + * Memory comparison for 8192 files: + * 1. With pre-sizing: 3.10 MB + * 2. Without pre-sizing: 9.60 MB + */ + if (itemCount > 10) listOfDocuments.ensureCapacity(itemCount) + + val idIndex = cursor.getColumnIndex(Document.COLUMN_DOCUMENT_ID) + if (idIndex == -1) { + ErrorLogger.logWarning( + "Missing ${Document.COLUMN_DOCUMENT_ID} column in child document cursor." + ) + return emptyList() + } + + val nameIndex = cursor.getColumnIndex(Document.COLUMN_DISPLAY_NAME) + val sizeIndex = cursor.getColumnIndex(Document.COLUMN_SIZE) + val modifiedIndex = cursor.getColumnIndex(Document.COLUMN_LAST_MODIFIED) + val mimeIndex = cursor.getColumnIndex(Document.COLUMN_MIME_TYPE) + if (mimeIndex == -1) { + ErrorLogger.logWarning( + "Missing ${Document.COLUMN_MIME_TYPE} column in child document cursor." + ) + return emptyList() + } + + val flagsIndex = cursor.getColumnIndex(Document.COLUMN_FLAGS) + + while (cursor.moveToNext()) { + val documentId = cursor.getString(idIndex) ?: continue + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) + + val documentName = getStringOrDefault(cursor, nameIndex) + val documentSize = getLongOrDefault(cursor, sizeIndex) + val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L) + val documentMimeType = cursor.getString(mimeIndex) ?: continue + + /** + * Default flags to 0 (no capabilities) when not included. + * Using `-1` here would make bitwise checks behave as "all flags set". + */ + val documentFlags = getLongOrDefault(cursor, flagsIndex, 0L).toInt() + + /* return correct document type */ + val childFile: DocumentFileCompat = + if (documentMimeType == Document.MIME_TYPE_DIR) { + TreeDocumentFileCompat( + context, documentUri, documentName, + documentSize, lastModifiedTime, + documentMimeType, documentFlags + ) + } else { + SingleDocumentFileCompat( + context, documentUri, documentName, + documentSize, lastModifiedTime, + documentMimeType, documentFlags + ) + } + childFile.parentFile = file + listOfDocuments.add(childFile) + } + } + + listOfDocuments + } catch (exception: Exception) { + ErrorLogger.logError("Exception while building child document list", exception) + emptyList() + } + } + // Make children uri for query. private fun createChildrenUri(uri: Uri): Uri { return DocumentsContract.buildChildDocumentsUriUsingTree( diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java new file mode 100644 index 0000000..c5f03d8 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -0,0 +1,65 @@ +package com.lazygeniouz.dfc.file; + +import android.provider.DocumentsContract.Document; +import java.lang.reflect.Method; +import java.util.Arrays; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DocumentFileCompatJavaApiTest { + + @Test + public void queryFactoriesAreCallableFromJavaSource() { + Query[] queries = new Query[] { + Query.filesOnly(), + Query.orderByDesc(Document.COLUMN_LAST_MODIFIED), + Query.in(Document.COLUMN_MIME_TYPE, "image/png", "image/jpeg"), + Query.notIn(Document.COLUMN_MIME_TYPE, "application/pdf"), + Query.limit(100), + }; + + assertEquals(5, queries.length); + } + + @SuppressWarnings("unused") + private static void listFilesOverloadsAreCallableFromJavaSource(DocumentFileCompat file) { + file.listFiles(); + file.listFiles(new String[] { Document.COLUMN_DISPLAY_NAME }); + file.listFiles(Query.limit(1)); + file.listFiles(new Query[] { Query.limit(1), Query.filesOnly() }); + } + + @Test + public void queryListingUsesOnlyListFilesAsPublicName() { + Method[] methods = DocumentFileCompat.class.getMethods(); + + assertFalse( + Arrays.stream(methods).anyMatch(method -> method.getName().equals("queryFiles")) + ); + assertTrue( + Arrays.stream(methods).anyMatch(method -> + method.getName().equals("listFiles") + && Arrays.equals(method.getParameterTypes(), new Class[] { Query[].class }) + ) + ); + } + + @Test + public void internalCursorHelperIsSyntheticOnJavaSide() { + Method[] methods = DocumentFileCompat.Companion.getClass().getMethods(); + + assertTrue( + Arrays.stream(methods).anyMatch(method -> + method.getName().startsWith("makeFromCursor") && method.isSynthetic() + ) + ); + assertFalse( + Arrays.stream(methods).anyMatch(method -> + method.getName().equals("makeFromCursor") && !method.isSynthetic() + ) + ); + } +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt new file mode 100644 index 0000000..33bfa54 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -0,0 +1,325 @@ +package com.lazygeniouz.dfc.file + +import android.provider.DocumentsContract.Document +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueryTest { + + @Test + fun `nameContains escapes sql like wildcards`() { + val selectionPart = Query.nameContains("100%_done\\ready").selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.second) + } + + @Test + fun `in with null adds is null clause`() { + val selectionPart = Query.`in`(Document.COLUMN_MIME_TYPE, null, "image/png") + .selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} IN (?)) OR (${Document.COLUMN_MIME_TYPE} IS NULL))", + selectionPart.first, + ) + assertEquals(listOf("image/png"), selectionPart.second) + } + + @Test + fun `notIn with null adds is not null clause`() { + val selectionPart = Query.notIn(Document.COLUMN_MIME_TYPE, null, "image/png") + .selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} NOT IN (?)) AND (${Document.COLUMN_MIME_TYPE} IS NOT NULL))", + selectionPart.first, + ) + assertEquals(listOf("image/png"), selectionPart.second) + } + + @Test + fun `equal with null becomes isNull selection`() { + val selectionPart = Query.equal(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `notEqual with null becomes isNotNull selection`() { + val selectionPart = Query.notEqual(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `equal compiles to equality selection`() { + val selectionPart = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.first) + assertEquals(listOf("report.pdf"), selectionPart.second) + } + + @Test + fun `notEqual compiles to inequality selection`() { + val selectionPart = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.first) + assertEquals(listOf("report.pdf"), selectionPart.second) + } + + @Test + fun `greaterThan compiles correctly`() { + val selectionPart = Query.greaterThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `greaterThanOrEqual compiles correctly`() { + val selectionPart = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} >= ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `lessThan compiles correctly`() { + val selectionPart = Query.lessThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `lessThanOrEqual compiles correctly`() { + val selectionPart = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} <= ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `between compiles correctly`() { + val selectionPart = Query.between(Document.COLUMN_SIZE, 10L, 20L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} BETWEEN ? AND ?)", selectionPart.first) + assertEquals(listOf("10", "20"), selectionPart.second) + } + + @Test(expected = IllegalArgumentException::class) + fun `between rejects reversed numeric range`() { + Query.between(Document.COLUMN_SIZE, 20L, 10L) + } + + @Test + fun `isNull compiles correctly`() { + val selectionPart = Query.isNull(Document.COLUMN_MIME_TYPE).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `isNotNull compiles correctly`() { + val selectionPart = Query.isNotNull(Document.COLUMN_MIME_TYPE).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `like compiles correctly`() { + val selectionPart = Query.like(Document.COLUMN_DISPLAY_NAME, "report%").selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("report%"), selectionPart.second) + } + + @Test + fun `notLike compiles correctly`() { + val selectionPart = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") + .selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("report%"), selectionPart.second) + } + + @Test + fun `filesOnly maps to mime type not equal directory`() { + val selectionPart = Query.filesOnly().selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} != ?)", selectionPart.first) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.second) + } + + @Test + fun `directoriesOnly maps to mime type equal directory`() { + val selectionPart = Query.directoriesOnly().selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} = ?)", selectionPart.first) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.second) + } + + @Test + fun `mimeTypeIn maps to in selection`() { + val selectionPart = Query.mimeTypeIn("image/png", "image/jpeg").selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IN (?,?))", selectionPart.first) + assertEquals(listOf("image/png", "image/jpeg"), selectionPart.second) + } + + @Test + fun `select returns projection query`() { + val query = Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) + + assertEquals( + listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + query.projectionColumns(), + ) + } + + @Test + fun `orderByAsc returns ascending sort query`() { + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} ASC", + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) + } + + @Test + fun `orderByDesc returns descending sort query`() { + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} DESC", + Query.orderByDesc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) + } + + @Test + fun `document contract columns are accepted as identifiers`() { + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_FLAGS, + Document.COLUMN_ICON, + Document.COLUMN_SUMMARY, + ).forEach { column -> + assertEquals("$column ASC", Query.orderByAsc(column).sortClause()) + } + } + + @Test + fun `limit returns limit query`() { + assertEquals(25, Query.limit(25).limitCount()) + } + + @Test + fun `offset returns offset query`() { + assertEquals(10, Query.offset(10).offsetCount()) + } + + @Test(expected = IllegalArgumentException::class) + fun `limit rejects negative values`() { + Query.limit(-1) + } + + @Test(expected = IllegalArgumentException::class) + fun `offset rejects negative values`() { + Query.offset(-1) + } + + @Test(expected = IllegalArgumentException::class) + fun `in rejects empty values`() { + Query.`in`(Document.COLUMN_DISPLAY_NAME) + } + + @Test(expected = IllegalArgumentException::class) + fun `notIn rejects empty values`() { + Query.notIn(Document.COLUMN_DISPLAY_NAME) + } + + @Test(expected = IllegalArgumentException::class) + fun `select rejects empty columns`() { + Query.select() + } + + @Test(expected = IllegalArgumentException::class) + fun `rawSelection rejects blank selection`() { + Query.rawSelection(" ") + } + + @Test(expected = IllegalArgumentException::class) + fun `orderByAsc rejects unsafe column name`() { + Query.orderByAsc("${Document.COLUMN_DISPLAY_NAME}; DROP TABLE documents") + } + + @Test(expected = IllegalArgumentException::class) + fun `select rejects unsafe column name`() { + Query.select("${Document.COLUMN_DISPLAY_NAME}; DROP TABLE documents") + } + + @Test(expected = IllegalArgumentException::class) + fun `selection rejects unsafe attribute name`() { + Query.equal("${Document.COLUMN_DISPLAY_NAME}) OR 1=1 --", "report.pdf") + } + + @Test(expected = IllegalArgumentException::class) + fun `orderByAsc rejects qualified column name`() { + Query.orderByAsc("documents.${Document.COLUMN_DISPLAY_NAME}") + } + + @Test(expected = IllegalArgumentException::class) + fun `selection rejects unsupported value type`() { + Query.equal(Document.COLUMN_DISPLAY_NAME, Any()) + } + + @Test(expected = IllegalArgumentException::class) + fun `selection rejects non finite number`() { + Query.greaterThan(Document.COLUMN_SIZE, Double.NaN) + } + + @Test(expected = IllegalArgumentException::class) + fun `in rejects unsupported value type`() { + Query.`in`(Document.COLUMN_DISPLAY_NAME, "report.pdf", Any()) + } +} + +private fun Query.projectionColumns(): List? { + return Query.projectionColumns(this) +} + +private fun Query.sortClause(): String? { + return Query.sortClause(this) +} + +private fun Query.limitCount(): Int? { + return Query.limitCount(this) +} + +private fun Query.offsetCount(): Int? { + return Query.offsetCount(this) +} + +private fun Query.selectionPart(): Pair>? { + return Query.selectionPart(this) +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt new file mode 100644 index 0000000..93cee17 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt @@ -0,0 +1,71 @@ +package com.lazygeniouz.dfc.file.internals + +import android.database.Cursor +import android.database.CursorWrapper +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract.Document +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.resolver.ResolverCompat +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class DocumentFileCompatMakeTest { + + @Test + fun `single makeFromCursor returns null when cursor read throws`() { + val context = RuntimeEnvironment.getApplication() + val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/document/root%2Fnotes.txt") + val cursor = throwingCursor(documentCursor("text/plain")) + + assertNull( + DocumentFileCompat.makeFromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) + } + + @Test + fun `tree makeFromCursor returns null when cursor read throws`() { + val context = RuntimeEnvironment.getApplication() + val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/tree/root/document/root") + val cursor = throwingCursor(documentCursor(Document.MIME_TYPE_DIR)) + + assertNull( + DocumentFileCompat.makeFromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + TreeDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) + } + + private fun documentCursor(mimeType: String): Cursor { + return MatrixCursor(ResolverCompat.fullProjection).apply { + addRow( + arrayOf( + "root/notes.txt", + "notes.txt", + 128L, + 0L, + mimeType, + Document.FLAG_SUPPORTS_WRITE, + ) + ) + } + } + + private fun throwingCursor(cursor: Cursor): Cursor { + return object : CursorWrapper(cursor) { + + override fun getString(columnIndex: Int): String? { + throw IllegalStateException("getString failed") + } + } + } +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt new file mode 100644 index 0000000..9d753f8 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -0,0 +1,509 @@ +package com.lazygeniouz.dfc.resolver + +import android.Manifest +import android.content.ContentResolver +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.CursorWrapper +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsProvider +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.file.internals.RawDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat +import java.io.FileNotFoundException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class ResolverCompatQueryTest { + + private lateinit var provider: TestDocumentsProvider + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + ShadowContentResolver.reset() + provider = TestDocumentsProvider() + provider.attachInfo( + context, + ProviderInfo().apply { + authority = TestDocumentsProvider.AUTHORITY + name = TestDocumentsProvider::class.java.name + exported = true + grantUriPermissions = true + readPermission = Manifest.permission.MANAGE_DOCUMENTS + writePermission = Manifest.permission.MANAGE_DOCUMENTS + }, + ) + ShadowContentResolver.registerProviderInternal(TestDocumentsProvider.AUTHORITY, provider) + } + + @Test + fun `query forwards api 26 bundle arguments`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.filesOnly(), + Query.limit(1), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + ), + provider.lastChildProjection?.toList(), + ) + assertEquals( + "(${Document.COLUMN_MIME_TYPE} != ?)", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + assertEquals(1, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_LIMIT)) + } + + @Test + fun `query select keeps internal projection and child document types`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + ), + provider.lastChildProjection?.toList(), + ) + + val file = children.first { it.name == "notes.txt" } + val directory = children.first { it.name == "photos" } + + assertTrue(file.isFile()) + assertFalse(file.isDirectory()) + assertEquals("text/plain", file.getType()) + assertSame(root, file.parentFile) + + assertTrue(directory.isDirectory()) + assertFalse(directory.isFile()) + assertNull(directory.getType()) + assertSame(root, directory.parentFile) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `query falls back to legacy sort only before api 26`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.filesOnly(), + Query.limit(1), + Query.orderByDesc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + ), + provider.lastChildProjection?.toList(), + ) + assertNull(provider.lastQueryArgs) + assertNull(provider.lastLegacySelection) + assertNull(provider.lastLegacySelectionArgs) + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} DESC", + provider.lastLegacySortOrder, + ) + assertEquals(2, children.size) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `legacy projection listFiles accepts empty projection`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + + val children = root.listFiles(emptyArray()) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + assertEquals(2, children.size) + } + + @Test + fun `query returns empty list when provider omits required document id column`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + provider.omitDocumentIdColumn = true + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertTrue(children.isEmpty()) + } + + @Test + fun `query returns empty list when provider omits required mime type column`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + provider.omitMimeTypeColumn = true + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertTrue(children.isEmpty()) + } + + @Test + fun `query forwards raw selection and offset`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + + root.listFiles( + Query.rawSelection("${Document.COLUMN_DISPLAY_NAME} LIKE ?", "notes%"), + Query.offset(2), + ) + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ?)", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf("notes%"), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + assertEquals(2, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_OFFSET)) + } + + @Test + fun `count returns zero when child cursor count throws`() { + val cursor = throwingCursor(childCursor(), throwOnCount = true) + + assertEquals(0, ResolverCompat.count(cursor)) + } + + @Test + fun `build document list returns empty list when child cursor iteration throws`() { + val context = RuntimeEnvironment.getApplication() + val root = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "root", + documentMimeType = Document.MIME_TYPE_DIR, + documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, + ) + val cursor = throwingCursor(childCursor(), throwOnGetString = true) + + val children = ResolverCompat.buildDocumentList(context, root, root.uri, cursor) + + assertTrue(children.isEmpty()) + } + + @Test + fun `query listFiles rejects non-directory tree documents`() { + val context = RuntimeEnvironment.getApplication() + val file = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "notes.txt", + documentMimeType = "text/plain", + ) + + try { + file.listFiles(Query.limit(1)) + fail("Expected UnsupportedOperationException") + } catch (exception: UnsupportedOperationException) { + assertEquals("Selected document is not a Directory.", exception.message) + } + } + + @Test + fun `query listFiles rejects single and raw documents`() { + val context = RuntimeEnvironment.getApplication() + val single = SingleDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "notes.txt", + documentMimeType = "text/plain", + ) + val raw = RawDocumentFileCompat(context, context.cacheDir) + + listOf(single, raw).forEach { file -> + try { + file.listFiles(Query.limit(1)) + fail("Expected UnsupportedOperationException") + } catch (exception: UnsupportedOperationException) { + assertEquals( + "Queries are only supported for DocumentsProvider-backed tree URIs.", + exception.message, + ) + } + } + } + + private class TestDocumentsProvider : DocumentsProvider() { + + var lastChildProjection: Array? = null + private set + + var lastQueryArgs: Bundle? = null + private set + + var lastLegacySelection: String? = null + private set + + var lastLegacySelectionArgs: Array? = null + private set + + var lastLegacySortOrder: String? = null + private set + + var omitDocumentIdColumn: Boolean = false + + var omitMimeTypeColumn: Boolean = false + + private val documents = listOf( + TestDocument( + id = ROOT_ID, + name = "root", + mimeType = Document.MIME_TYPE_DIR, + flags = Document.FLAG_DIR_SUPPORTS_CREATE, + ), + TestDocument( + id = FILE_ID, + name = "notes.txt", + mimeType = "text/plain", + size = 128L, + flags = Document.FLAG_SUPPORTS_WRITE, + ), + TestDocument( + id = DIR_ID, + name = "photos", + mimeType = Document.MIME_TYPE_DIR, + flags = Document.FLAG_DIR_SUPPORTS_CREATE, + ), + ) + + override fun onCreate(): Boolean = true + + override fun queryRoots(projection: Array?): Cursor { + return MatrixCursor(projection ?: emptyArray()) + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val document = documents.firstOrNull { it.id == documentId } + ?: throw FileNotFoundException(documentId) + + return cursorOf(projection, listOf(document)) + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String?, + ): Cursor { + lastChildProjection = projection?.copyProjection() + lastLegacySelection = null + lastLegacySelectionArgs = null + lastLegacySortOrder = sortOrder + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + queryArgs: Bundle?, + ): Cursor { + lastChildProjection = projection?.copyProjection() + lastQueryArgs = queryArgs?.let(::Bundle) + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + throw FileNotFoundException(documentId) + } + + private fun cursorOf( + projection: Array?, + documents: List, + ): Cursor { + val columns = (projection?.toList() ?: ResolverCompat.fullProjection.toList()) + .filterNot { omitDocumentIdColumn && it == Document.COLUMN_DOCUMENT_ID } + .filterNot { omitMimeTypeColumn && it == Document.COLUMN_MIME_TYPE } + return MatrixCursor(columns.toTypedArray()).apply { + documents.forEach { document -> + addRow(columns.map { column -> document.valueFor(column) }) + } + } + } + + private data class TestDocument( + val id: String, + val name: String, + val mimeType: String, + val size: Long = 0L, + val lastModified: Long = 0L, + val flags: Int = 0, + ) { + + fun valueFor(column: String): Any? { + return when (column) { + Document.COLUMN_DOCUMENT_ID -> id + Document.COLUMN_DISPLAY_NAME -> name + Document.COLUMN_SIZE -> size + Document.COLUMN_LAST_MODIFIED -> lastModified + Document.COLUMN_MIME_TYPE -> mimeType + Document.COLUMN_FLAGS -> flags + else -> null + } + } + } + + private fun Array.copyProjection(): Array { + return Array(size) { index -> this[index] } + } + + companion object { + const val AUTHORITY = "com.lazygeniouz.dfc.test.documents" + private const val ROOT_ID = "root" + private const val FILE_ID = "root/notes.txt" + private const val DIR_ID = "root/photos" + + fun rootDocumentUri(): Uri { + return DocumentsContract.buildDocumentUriUsingTree( + DocumentsContract.buildTreeDocumentUri(AUTHORITY, ROOT_ID), + ROOT_ID, + ) + } + } + } +} + +private fun childCursor(): Cursor { + return MatrixCursor(ResolverCompat.fullProjection).apply { + addRow( + arrayOf( + "root/notes.txt", + "notes.txt", + 128L, + 0L, + "text/plain", + Document.FLAG_SUPPORTS_WRITE, + ) + ) + } +} + +private fun throwingCursor( + cursor: Cursor, + throwOnCount: Boolean = false, + throwOnGetString: Boolean = false, +): Cursor { + return object : CursorWrapper(cursor) { + + override fun getCount(): Int { + if (throwOnCount) throw IllegalStateException("count failed") + return super.getCount() + } + + override fun getString(columnIndex: Int): String? { + if (throwOnGetString) throw IllegalStateException("getString failed") + return super.getString(columnIndex) + } + } +}