From 7b48cd01c9be7a4ea2c210f77a917371dd3e999c Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 29 Mar 2026 14:40:42 +0530 Subject: [PATCH 01/36] improve: add query api. --- .../dfc/controller/DocumentController.kt | 11 +- .../dfc/file/DocumentFileCompat.kt | 16 +- .../java/com/lazygeniouz/dfc/file/Query.kt | 513 ++++++++++++++++++ .../file/internals/RawDocumentFileCompat.kt | 13 + .../internals/SingleDocumentFileCompat.kt | 10 + .../file/internals/TreeDocumentFileCompat.kt | 5 + .../com/lazygeniouz/dfc/logger/ErrorLogger.kt | 9 +- .../dfc/resolver/ResolverCompat.kt | 223 +++++++- 8 files changed, 767 insertions(+), 33 deletions(-) create mode 100644 dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt 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..0485ed5 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.queryFiles(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 1d279df..d6408b8 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -73,6 +73,20 @@ abstract class DocumentFileCompat( */ abstract fun listFiles(projection: Array): List + /** + * List child documents using [Query] clauses. + * + * This is only supported for tree-backed directories. + * + * API support for SAF child-document queries: + * + * - API 21-25: only [Query.select], [Query.projection], [Query.orderByAsc], and [Query.orderByDesc] are honored. + * - API 26+: filter queries, [Query.limit], [Query.offset], and [Query.rawSelection] are also forwarded. + * + * **NOTE: Unsupported clauses are ignored and logged.** + */ + abstract fun listFiles(vararg queries: Query): List + /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. * @@ -255,4 +269,4 @@ abstract class DocumentFileCompat( return paths.size >= 2 && "tree" == paths[0] } } -} \ 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..90d8eb8 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -0,0 +1,513 @@ +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.projection +import com.lazygeniouz.dfc.file.Query.Companion.rawSelection + +/** + * Query clauses for [DocumentFileCompat.listFiles]. + * + * For tree-backed SAF directories: + * + * - API 21-25: only [projection], [orderByAsc], and [orderByDesc] are honored. + * - API 26+: filter queries, [limit], [offset], and [rawSelection] are also forwarded. + * + * Unsupported clauses are ignored and logged. + */ +sealed class Query { + + internal data class Projection(val columns: List) : Query() + + internal data class Sort(val column: String, val descending: Boolean) : Query() + + internal data class Limit(val count: Int) : Query() + + internal data class Offset(val count: Int) : Query() + + internal data class Selection( + val attribute: String, + val operator: Operator, + val values: List, + ) : Query() + + internal data class RawSelection( + val selection: String, + val args: List, + ) : Query() + + internal enum class Operator { + EQUAL, + NOT_EQUAL, + IN, + NOT_IN, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL, + BETWEEN, + IS_NULL, + IS_NOT_NULL, + LIKE, + NOT_LIKE, + } + + companion object { + + /** + * Fetch only the given columns. + * + * Honored on API 21+. + */ + @JvmStatic + fun select(vararg columns: String): Query { + return Projection(columns.toList()) + } + + /** + * Fetch only the given columns. + * + * Honored on API 21+. + */ + @Deprecated( + message = "Use select(...) instead.", + replaceWith = ReplaceWith("select(*columns)"), + ) + @JvmStatic + fun projection(vararg columns: String): Query { + return select(*columns) + } + + /** + * Sort ascending by the given column. + * + * Honored on API 21+. + */ + @JvmStatic + fun orderByAsc(column: String): Query { + return Sort(column, descending = false) + } + + /** + * Sort descending by the given column. + * + * Honored on API 21+. + */ + @JvmStatic + fun orderByDesc(column: String): Query { + return Sort(column, descending = true) + } + + /** + * Limit the number of returned child documents. + * + * Honored on API 26+. + */ + @JvmStatic + fun limit(count: Int): Query { + require(count >= 0) { "limit must be >= 0" } + return Limit(count) + } + + /** + * Skip the first [count] child documents. + * + * Honored on API 26+. + */ + @JvmStatic + fun offset(count: Int): Query { + require(count >= 0) { "offset must be >= 0" } + return Offset(count) + } + + /** + * Attribute equals [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun equal(attribute: String, value: Any?): Query { + return if (value == null) isNull(attribute) else Selection( + attribute, + Operator.EQUAL, + listOf(value), + ) + } + + /** + * Attribute does not equal [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun notEqual(attribute: String, value: Any?): Query { + return if (value == null) isNotNull(attribute) else Selection( + attribute, + Operator.NOT_EQUAL, + listOf(value), + ) + } + + /** + * Attribute equals one of [values]. + * + * Honored on API 26+. + */ + @JvmStatic + fun `in`(attribute: String, vararg values: Any?): Query { + require(values.isNotEmpty()) { "in requires at least one value" } + return Selection(attribute, Operator.IN, values.toList()) + } + + /** + * Attribute does not equal any of [values]. + * + * Honored on API 26+. + */ + @JvmStatic + fun notIn(attribute: String, vararg values: Any?): Query { + require(values.isNotEmpty()) { "notIn requires at least one value" } + return Selection(attribute, Operator.NOT_IN, values.toList()) + } + + /** + * Attribute is greater than [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun greaterThan(attribute: String, value: Any): Query { + return Selection(attribute, Operator.GREATER_THAN, listOf(value)) + } + + /** + * Attribute is greater than or equal to [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun greaterThanOrEqual(attribute: String, value: Any): Query { + return Selection(attribute, Operator.GREATER_THAN_OR_EQUAL, listOf(value)) + } + + /** + * Attribute is less than [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun lessThan(attribute: String, value: Any): Query { + return Selection(attribute, Operator.LESS_THAN, listOf(value)) + } + + /** + * Attribute is less than or equal to [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun lessThanOrEqual(attribute: String, value: Any): Query { + return Selection(attribute, Operator.LESS_THAN_OR_EQUAL, listOf(value)) + } + + /** + * Attribute is between [start] and [endInclusive]. + * + * Honored on API 26+. + */ + @JvmStatic + fun between(attribute: String, start: Any, endInclusive: Any): Query { + return Selection(attribute, Operator.BETWEEN, listOf(start, endInclusive)) + } + + /** + * Attribute is null. + * + * Honored on API 26+. + */ + @JvmStatic + fun isNull(attribute: String): Query { + return Selection(attribute, Operator.IS_NULL, emptyList()) + } + + /** + * Attribute is not null. + * + * Honored on API 26+. + */ + @JvmStatic + fun isNotNull(attribute: String): Query { + return Selection(attribute, Operator.IS_NOT_NULL, emptyList()) + } + + /** + * Attribute matches the SQL LIKE [pattern]. + * + * Honored on API 26+. + */ + @JvmStatic + fun like(attribute: String, pattern: String): Query { + return Selection(attribute, Operator.LIKE, listOf(pattern)) + } + + /** + * Attribute does not match the SQL LIKE [pattern]. + * + * Honored on API 26+. + */ + @JvmStatic + fun notLike(attribute: String, pattern: String): Query { + return Selection(attribute, Operator.NOT_LIKE, listOf(pattern)) + } + + /** + * Pass a raw SQL-style selection expression. + * + * Honored on API 26+. + */ + @JvmStatic + fun rawSelection(selection: String, vararg args: String): Query { + require(selection.isNotBlank()) { "selection must not be blank" } + return RawSelection(selection, args.toList()) + } + + /** + * Exclude directories. + * + * Honored on API 26+. + */ + @JvmStatic + fun filesOnly(): Query { + return notEqual(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Include only directories. + * + * Honored on API 26+. + */ + @JvmStatic + fun directoriesOnly(): Query { + return equal(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Name equals [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun nameEquals(value: String): Query { + return equal(Document.COLUMN_DISPLAY_NAME, value) + } + + /** + * Name contains [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun nameContains(value: String): Query { + return like( + Document.COLUMN_DISPLAY_NAME, + "%${escapeLikePattern(value)}%", + ) + } + + /** + * MIME type equals [value]. + * + * Honored on API 26+. + */ + @JvmStatic + fun mimeType(value: String): Query { + return equal(Document.COLUMN_MIME_TYPE, value) + } + + /** + * MIME type equals one of [values]. + * + * Honored on API 26+. + */ + @JvmStatic + fun mimeTypeIn(vararg values: String): Query { + return `in`(Document.COLUMN_MIME_TYPE, *values) + } + + /** + * Size is greater than [bytes]. + * + * Honored on API 26+. + */ + @JvmStatic + fun sizeGreaterThan(bytes: Long): Query { + return greaterThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Size is less than [bytes]. + * + * Honored on API 26+. + */ + @JvmStatic + fun sizeLessThan(bytes: Long): Query { + return lessThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Last modified time is after [timestampMillis]. + * + * Honored on API 26+. + */ + @JvmStatic + fun lastModifiedAfter(timestampMillis: Long): Query { + return greaterThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + /** + * Last modified time is before [timestampMillis]. + * + * Honored on API 26+. + */ + @JvmStatic + fun lastModifiedBefore(timestampMillis: Long): Query { + return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + private fun escapeLikePattern(value: String): String { + return value + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + } + } +} + +internal object QueryDefaults { + val DEFAULT_PROJECTION = listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_FLAGS, + ) +} + +internal data class SelectionPart( + val selection: String, + val args: List, +) + +internal fun Query.Selection.toSelectionPart(): SelectionPart { + val attribute = attribute + + return when (operator) { + Query.Operator.EQUAL -> SelectionPart("($attribute = ?)", listOf(values.first().toSqlArg())) + Query.Operator.NOT_EQUAL -> SelectionPart( + "($attribute != ?)", + listOf(values.first().toSqlArg()) + ) + + Query.Operator.IN -> buildInSelection(attribute, values) + Query.Operator.NOT_IN -> buildNotInSelection(attribute, values) + + Query.Operator.GREATER_THAN -> + SelectionPart("($attribute > ?)", listOf(values.first().toSqlArg())) + + Query.Operator.GREATER_THAN_OR_EQUAL -> + SelectionPart("($attribute >= ?)", listOf(values.first().toSqlArg())) + + Query.Operator.LESS_THAN -> + SelectionPart("($attribute < ?)", listOf(values.first().toSqlArg())) + + Query.Operator.LESS_THAN_OR_EQUAL -> + SelectionPart("($attribute <= ?)", listOf(values.first().toSqlArg())) + + Query.Operator.BETWEEN -> SelectionPart( + "($attribute BETWEEN ? AND ?)", + listOf(values[0].toSqlArg(), values[1].toSqlArg()), + ) + + Query.Operator.IS_NULL -> SelectionPart("($attribute IS NULL)", emptyList()) + Query.Operator.IS_NOT_NULL -> SelectionPart("($attribute IS NOT NULL)", emptyList()) + Query.Operator.LIKE -> + SelectionPart("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + + Query.Operator.NOT_LIKE -> + SelectionPart("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + } +} + +private fun buildInSelection(attribute: String, values: List): SelectionPart { + val nonNullValues = values.filterNotNull() + val hasNull = values.any { it == null } + + return when { + nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NULL)", emptyList()) + hasNull -> SelectionPart( + "(($attribute IN (${nonNullValues.joinToString(",") { "?" }})) OR ($attribute IS NULL))", + nonNullValues.map { it.toSqlArg() }, + ) + + else -> SelectionPart( + "($attribute IN (${nonNullValues.joinToString(",") { "?" }}))", + nonNullValues.map { it.toSqlArg() }, + ) + } +} + +private fun buildNotInSelection(attribute: String, values: List): SelectionPart { + val nonNullValues = values.filterNotNull() + val hasNull = values.any { it == null } + + return when { + nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NOT NULL)", emptyList()) + hasNull -> SelectionPart( + "(($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }})) AND ($attribute IS NOT NULL))", + nonNullValues.map { it.toSqlArg() }, + ) + + else -> SelectionPart( + "($attribute NOT IN (${nonNullValues.joinToString(",") { "?" }}))", + nonNullValues.map { it.toSqlArg() }, + ) + } +} + +private fun Any?.toSqlArg(): String { + return when (this) { + null -> "null" + is Boolean -> if (this) "1" else "0" + else -> toString() + } +} + +internal fun Query.describe(): String { + return when (this) { + is Query.Projection -> "projection" + is Query.Sort -> if (descending) "orderByDesc($column)" else "orderByAsc($column)" + is Query.Limit -> "limit($count)" + is Query.Offset -> "offset($count)" + is Query.Selection -> when (operator) { + Query.Operator.EQUAL -> "equal($attribute)" + Query.Operator.NOT_EQUAL -> "notEqual($attribute)" + Query.Operator.IN -> "in($attribute)" + Query.Operator.NOT_IN -> "notIn($attribute)" + Query.Operator.GREATER_THAN -> "greaterThan($attribute)" + Query.Operator.GREATER_THAN_OR_EQUAL -> "greaterThanOrEqual($attribute)" + Query.Operator.LESS_THAN -> "lessThan($attribute)" + Query.Operator.LESS_THAN_OR_EQUAL -> "lessThanOrEqual($attribute)" + Query.Operator.BETWEEN -> "between($attribute)" + Query.Operator.IS_NULL -> "isNull($attribute)" + Query.Operator.IS_NOT_NULL -> "isNotNull($attribute)" + Query.Operator.LIKE -> "like($attribute)" + Query.Operator.NOT_LIKE -> "notLike($attribute)" + } + + is Query.RawSelection -> "rawSelection" + } +} 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..7755a03 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 @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.logger.ErrorLogger.logError import java.io.File @@ -112,6 +113,18 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return listFiles() } + /** + * Raw file queries are not backed by a DocumentsProvider and therefore don't support + * provider-level query arguments. + * + * @throws UnsupportedOperationException + */ + override fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException( + "Queries are only supported for DocumentsProvider-backed tree URIs." + ) + } + /** * This will return the children count in the directory. * 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 69fa5d5..541e4e4 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 @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.provider.DocumentsContract import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -59,6 +60,15 @@ internal class SingleDocumentFileCompat( return listFiles() } + /** + * Single document Uris don't have children, so queries are not applicable. + * + * @throws UnsupportedOperationException + */ + override fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException() + } + /** * No [listFiles], no children, no count. * 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..3d39e4d 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. * 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..ee46f31 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}") } -} \ No newline at end of file + + /** + * Log warning to logcat for non-fatal behavior differences. + */ + internal fun logWarning(message: String) { + Log.w("DocumentFileCompat", message) + } +} 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 74c1afe..66a3282 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,15 @@ 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.QueryDefaults +import com.lazygeniouz.dfc.file.describe +import com.lazygeniouz.dfc.file.toSelectionPart import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat import com.lazygeniouz.dfc.logger.ErrorLogger @@ -120,17 +126,196 @@ internal object ResolverCompat { context: Context, file: DocumentFileCompat, projection: Array = fullProjection, + ): List { + return queryFiles(context, file, Query.select(*projection)) + } + + /** + * Queries the ContentResolver using provider-level query arguments and builds + * a list of [DocumentFileCompat]. + */ + internal fun queryFiles( + context: Context, + file: DocumentFileCompat, + vararg queries: Query, ): List { val uri = file.uri val childrenUri = createChildrenUri(uri) - val listOfDocuments = arrayListOf() + val projectionQueries = queries.filterIsInstance() + val projection = LinkedHashSet().apply { + if (projectionQueries.isEmpty()) { + addAll(QueryDefaults.DEFAULT_PROJECTION) + } else { + projectionQueries.forEach { addAll(it.columns) } + } + + // Ensure document id is always available so we can build child document Uris. + add(Document.COLUMN_DOCUMENT_ID) + }.toTypedArray() - // ensure `Document.COLUMN_DOCUMENT_ID` is always included - val finalProjection = if (Document.COLUMN_DOCUMENT_ID !in projection) { - arrayOf(Document.COLUMN_DOCUMENT_ID, *projection) - } else projection + val ignoredQueries = mutableListOf() + val selectionParts = mutableListOf() + val selectionArgs = mutableListOf() + val sortClauses = mutableListOf() + + var limit: Int? = null + var offset: Int? = null + + queries.forEach { query -> + when (query) { + is Query.Projection -> Unit + is Query.Sort -> { + sortClauses += "${query.column} ${if (query.descending) "DESC" else "ASC"}" + } - val cursor = getCursor(context, childrenUri, finalProjection) ?: return emptyList() + is Query.Limit -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) limit = query.count + else ignoredQueries += query + } + + is Query.Offset -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = query.count + else ignoredQueries += query + } + + is Query.Selection -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val compiledSelection = query.toSelectionPart() + selectionParts += compiledSelection.selection + selectionArgs += compiledSelection.args + } else { + ignoredQueries += query + } + } + + is Query.RawSelection -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += "(${query.selection})" + selectionArgs += query.args + } 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 + } + + 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]. + */ + internal fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { + return try { + context.contentResolver.query( + uri, projection, null, null, null + ) + } 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 + } + } + + /** + * 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 { + 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) { + 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 { it.describe() }) + append(". SAF child-document filtering, limit, and offset require API 26+.") + } + ) + } + + private fun buildDocumentList( + context: Context, + file: DocumentFileCompat, + treeUri: Uri, + cursor: Cursor, + ): List { + val listOfDocuments = arrayListOf() cursor.use { val itemCount = cursor.count @@ -144,9 +329,7 @@ internal object ResolverCompat { */ 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) @@ -155,7 +338,7 @@ internal object ResolverCompat { while (cursor.moveToNext()) { val documentId = cursor.getString(idIndex) ?: continue - val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) val documentName = getStringOrDefault(cursor, nameIndex) val documentSize = getLongOrDefault(cursor, sizeIndex) @@ -182,30 +365,10 @@ internal object ResolverCompat { return listOfDocuments } - /** - * Get [Cursor] from [ContentResolver.query] with given [projection] on a given [uri]. - */ - fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { - return try { - context.contentResolver.query( - uri, projection, null, null, null - ) - } 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 - } - } - // Make children uri for query. private fun createChildrenUri(uri: Uri): Uri { return DocumentsContract.buildChildDocumentsUriUsingTree( uri, DocumentsContract.getDocumentId(uri) ) } -} \ No newline at end of file +} From eb8fb30dabd347ed1ef7251df3e80745a21fdc27 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 29 Mar 2026 14:43:01 +0530 Subject: [PATCH 02/36] improve: query tests. --- dfc/build.gradle | 4 + .../com/lazygeniouz/dfc/file/QueryTest.kt | 286 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt diff --git a/dfc/build.gradle b/dfc/build.gradle index 6aca964..b7ffb56 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -24,4 +24,8 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + testImplementation "junit:junit:4.13.2" } \ No newline at end of file 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..38b3aa9 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -0,0 +1,286 @@ +package com.lazygeniouz.dfc.file + +import android.provider.DocumentsContract.Document +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueryTest { + + @Test + fun `nameContains escapes sql like wildcards`() { + val query = Query.nameContains("100%_done\\ready") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.selection, + ) + assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.args) + } + + @Test + fun `in with null adds is null clause`() { + val query = Query.`in`(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} IN (?)) OR (${Document.COLUMN_MIME_TYPE} IS NULL))", + selectionPart.selection, + ) + assertEquals(listOf("image/png"), selectionPart.args) + } + + @Test + fun `notIn with null adds is not null clause`() { + val query = Query.notIn(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} NOT IN (?)) AND (${Document.COLUMN_MIME_TYPE} IS NOT NULL))", + selectionPart.selection, + ) + assertEquals(listOf("image/png"), selectionPart.args) + } + + @Test + fun `equal with null becomes isNull selection`() { + val query = Query.equal(Document.COLUMN_MIME_TYPE, null) as Query.Selection + + assertEquals(Query.Operator.IS_NULL, query.operator) + assertTrue(query.values.isEmpty()) + } + + @Test + fun `notEqual with null becomes isNotNull selection`() { + val query = Query.notEqual(Document.COLUMN_MIME_TYPE, null) as Query.Selection + + assertEquals(Query.Operator.IS_NOT_NULL, query.operator) + assertTrue(query.values.isEmpty()) + } + + @Test + fun `equal compiles to equality selection`() { + val query = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.selection) + assertEquals(listOf("report.pdf"), selectionPart.args) + } + + @Test + fun `notEqual compiles to inequality selection`() { + val query = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.selection) + assertEquals(listOf("report.pdf"), selectionPart.args) + } + + @Test + fun `greaterThan compiles correctly`() { + val query = Query.greaterThan(Document.COLUMN_SIZE, 1024L) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.selection) + assertEquals(listOf("1024"), selectionPart.args) + } + + @Test + fun `greaterThanOrEqual compiles correctly`() { + val query = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_SIZE} >= ?)", selectionPart.selection) + assertEquals(listOf("1024"), selectionPart.args) + } + + @Test + fun `lessThan compiles correctly`() { + val query = Query.lessThan(Document.COLUMN_SIZE, 1024L) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.selection) + assertEquals(listOf("1024"), selectionPart.args) + } + + @Test + fun `lessThanOrEqual compiles correctly`() { + val query = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_SIZE} <= ?)", selectionPart.selection) + assertEquals(listOf("1024"), selectionPart.args) + } + + @Test + fun `between compiles correctly`() { + val query = Query.between(Document.COLUMN_SIZE, 10L, 20L) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_SIZE} BETWEEN ? AND ?)", selectionPart.selection) + assertEquals(listOf("10", "20"), selectionPart.args) + } + + @Test + fun `isNull compiles correctly`() { + val query = Query.isNull(Document.COLUMN_MIME_TYPE) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.selection) + assertTrue(selectionPart.args.isEmpty()) + } + + @Test + fun `isNotNull compiles correctly`() { + val query = Query.isNotNull(Document.COLUMN_MIME_TYPE) as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.selection) + assertTrue(selectionPart.args.isEmpty()) + } + + @Test + fun `like compiles correctly`() { + val query = Query.like(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.selection, + ) + assertEquals(listOf("report%"), selectionPart.args) + } + + @Test + fun `notLike compiles correctly`() { + val query = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection + + val selectionPart = query.toSelectionPart() + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')", + selectionPart.selection, + ) + assertEquals(listOf("report%"), selectionPart.args) + } + + @Test + fun `filesOnly maps to mime type not equal directory`() { + val query = Query.filesOnly() as Query.Selection + + assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) + assertEquals(Query.Operator.NOT_EQUAL, query.operator) + assertEquals(listOf(Document.MIME_TYPE_DIR), query.values) + } + + @Test + fun `directoriesOnly maps to mime type equal directory`() { + val query = Query.directoriesOnly() as Query.Selection + + assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) + assertEquals(Query.Operator.EQUAL, query.operator) + assertEquals(listOf(Document.MIME_TYPE_DIR), query.values) + } + + @Test + fun `mimeTypeIn maps to in selection`() { + val query = Query.mimeTypeIn("image/png", "image/jpeg") as Query.Selection + + assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) + assertEquals(Query.Operator.IN, query.operator) + assertEquals(listOf("image/png", "image/jpeg"), query.values) + } + + @Test + fun `select returns projection query`() { + val query = Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) + as Query.Projection + + assertEquals( + listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + query.columns, + ) + } + + @Test + fun `projection delegates to select`() { + @Suppress("DEPRECATION") + val query = Query.projection(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) + as Query.Projection + + assertEquals( + listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + query.columns, + ) + } + + @Test + fun `orderByAsc returns ascending sort query`() { + val query = Query.orderByAsc(Document.COLUMN_DISPLAY_NAME) as Query.Sort + + assertEquals(Document.COLUMN_DISPLAY_NAME, query.column) + assertFalse(query.descending) + } + + @Test + fun `orderByDesc returns descending sort query`() { + val query = Query.orderByDesc(Document.COLUMN_DISPLAY_NAME) as Query.Sort + + assertEquals(Document.COLUMN_DISPLAY_NAME, query.column) + assertTrue(query.descending) + } + + @Test + fun `limit returns limit query`() { + val query = Query.limit(25) as Query.Limit + + assertEquals(25, query.count) + } + + @Test + fun `offset returns offset query`() { + val query = Query.offset(10) as Query.Offset + + assertEquals(10, query.count) + } + + @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 `rawSelection rejects blank selection`() { + Query.rawSelection(" ") + } +} From f027246e818f03a0a339da4f75c760444bbbdfa4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 29 Mar 2026 14:43:10 +0530 Subject: [PATCH 03/36] update: sample for query checks. --- .../performance/ProjectionPerformance.kt | 69 +++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 24 +++++-- 2 files changed, 86 insertions(+), 7 deletions(-) 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..56306f6 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 @@ -2,9 +2,11 @@ package com.lazygeniouz.filecompat.example.performance import android.content.Context import android.net.Uri +import android.os.Build import android.provider.DocumentsContract.Document import androidx.documentfile.provider.DocumentFile import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.filecompat.example.performance.Performance.measureTimeSeconds object ProjectionPerformance { @@ -31,6 +33,14 @@ object ProjectionPerformance { results += "=".repeat(48).plus("\n\n") + // Test 5: Projection overload parity vs Query.select + results += testSelectQueryParity(context, uri) + "\n\n" + + // Test 6: Provider query correctness for filesOnly + results += testFilesOnlyQuery(context, uri) + "\n\n" + + results += "=".repeat(48).plus("\n\n") + return results } @@ -119,4 +129,63 @@ object ProjectionPerformance { "DFC.count() = ${dfcCountTime}s\n" + "DFC.listFiles().size = ${dfcListSizeTime}s" } + + private fun testSelectQueryParity(context: Context, uri: Uri): String { + val documentFile = DocumentFileCompat.fromTreeUri(context, uri) + ?: return "Query.select parity:\nFailed to access directory" + + val projection = arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + ) + + var projectionCount = 0 + val projectionTime = measureTimeSeconds { + projectionCount = documentFile.listFiles(projection).size + } + + var queryCount = 0 + val queryTime = measureTimeSeconds { + queryCount = documentFile.listFiles( + Query.select( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + ) + ).size + } + + return "Projection overload vs Query.select:\n" + + "Projection count = $projectionCount (${projectionTime}s)\n" + + "Query.select count = $queryCount (${queryTime}s)\n" + + "Match = ${projectionCount == queryCount}" + } + + private fun testFilesOnlyQuery(context: Context, uri: Uri): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return "Query.filesOnly correctness:\nSkipped on API < 26 (query filters are ignored)." + } + + val documentFile = DocumentFileCompat.fromTreeUri(context, uri) + ?: return "Query.filesOnly correctness:\nFailed to access directory" + + var providerResult = emptyList() + val providerTime = measureTimeSeconds { + providerResult = documentFile.listFiles(Query.filesOnly()) + } + + var clientSideResult = emptyList() + val clientSideTime = measureTimeSeconds { + clientSideResult = documentFile.listFiles().filter { !it.isDirectory() } + } + + val providerUris = providerResult.map { it.uri }.toSet() + val clientSideUris = clientSideResult.map { it.uri }.toSet() + + return "Query.filesOnly correctness:\n" + + "Provider query count = ${providerResult.size} (${providerTime}s)\n" + + "Client-side filter count = ${clientSideResult.size} (${clientSideTime}s)\n" + + "Match = ${providerUris == clientSideUris}" + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 7a537b1..38ff10f 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,8 +48,9 @@ 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" /> - \ No newline at end of file + From 20503eb372e8aae712970cabc9cce808af01f0b6 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 29 Mar 2026 14:50:09 +0530 Subject: [PATCH 04/36] update: readme. --- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 12717c9..10e2bae 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,36 @@ # DocumentFileCompat -A faster alternative to AndroidX's DocumentFile. +AndroidX `DocumentFile`, without the usual performance tax. + +`DocumentFileCompat` is a faster, practical alternative to AndroidX's `DocumentFile` for working +with SAF tree and document Uris. It reduces repeated `ContentResolver` lookups by fetching the +metadata you actually need up front, so directory listing and file inspection stay usable even in +large folders. ### The Problem with DocumentFile -It is horribly slow!\ -For **almost** every method, there is a query to **ContentResolver**. +`DocumentFile` is convenient, but it can get painfully slow. + +For many common operations, it repeatedly queries `ContentResolver`. That cost adds up fast when +you: + +- list large directories +- call `findFile()` +- read names, sizes, MIME types, and timestamps for many children +- build your own file models from SAF results -The most common one is `DocumentFile.findFile()`, `DocumentFile.getName()` and other is building a -Custom Data Model with multiple parameters.\ -This can take like a horrible amount of time. +### Why DocumentFileCompat -### Solution +`DocumentFileCompat` keeps the API familiar, but fetches relevant file metadata in a single pass +when possible. -`DocumentFileCompat` is a drop-in replacement which gathers relevant parameters when querying for -files.\ -The performance can sometimes peak to 2x or quite higher, depending on the size of the folder. +That means you get: + +- faster directory listing +- fewer redundant SAF queries +- custom projections when you only need a few columns +- query support for filtering, sorting, paging, and projection +- a drop-in-friendly replacement for most `DocumentFile` usage Check the screenshots below: @@ -23,15 +38,11 @@ Check the screenshots below:       [](/screenshots/filecompat_file_perf.jpeg) -**48 whopping seconds for directory listing compared to 3.5!** (Obviously, No competition with the -Native File API).\ -Also extracting file information does not take that much time but the improvement is still -significant. +One local benchmark dropped a large directory listing from **48 seconds to 3.5 seconds**.\ +It still does not beat the native `File` API, but for SAF-heavy code it is a major improvement. -**Note:** `DocumentFileCompat` is something that I used internally for some projects & therefore I -didn't do much of file manipulation with it (only delete files) and therefore this API does -not offer too much out of the box.\ -This is now a completely usable alternative to `DocumentFile`. +What started as an internal utility is now a solid, usable alternative to `DocumentFile` for +real-world SAF workflows. ### Installation @@ -57,9 +68,58 @@ dependencies { ### Usage -Almost all of the methods & getters are identical to `DocumentFile`, you'll just have to replace the -imports.\ -Additional methods like `copyTo(destination: Uri)` & `copyFrom(source: Uri)` are added as well. +Most methods and getters are intentionally close to AndroidX `DocumentFile`, so migration is mostly +about swapping imports. + +Extras include: + +- `copyTo(destination: Uri)` +- `copyFrom(source: Uri)` +- custom projections +- query-based child listing + +Basic example: + +```kotlin +val directory = DocumentFileCompat.fromTreeUri(context, treeUri) ?: return + +val recentFiles = directory.listFiles() +``` + +#### Querying Child Documents + +`DocumentFileCompat` now supports `listFiles(vararg queries: Query)` for tree-backed directories +when you need filtering, sorting, paging, or projection without dropping down to raw resolver code. + +```kotlin +import android.provider.DocumentsContract +import com.lazygeniouz.dfc.file.Query + +val directory = DocumentFileCompat.fromTreeUri(context, treeUri) ?: return + +val files = directory.listFiles( + Query.filesOnly(), + Query.orderByDesc(DocumentsContract.Document.COLUMN_LAST_MODIFIED), + Query.limit(100), + Query.select( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + ), +) +``` + +Notes: + +- `listFiles(vararg queries: Query)` is only supported for tree-backed `DocumentsProvider` + directories. +- On API 21-25, only `Query.select(...)`, `Query.projection(...)`, `Query.orderByAsc(...)`, and + `Query.orderByDesc(...)` are honored. +- On API 26+, filter queries, `Query.limit(...)`, `Query.offset(...)`, and + `Query.rawSelection(...)` are also forwarded. +- Unsupported queries are ignored and logged. +- Providers may still ignore supported query arguments. `DocumentFileCompat` forwards them, but the + underlying provider decides what gets honored. #### Reference: From 771f6ab3a62ebd391c98151294771afcfd84bf19 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 26 Jul 2026 18:11:12 +0530 Subject: [PATCH 05/36] fix: harden query api. --- .github/workflows/build.yml | 14 +- README.md | 2 +- .../performance/ProjectionPerformance.kt | 12 +- build.gradle | 4 +- .../dfc/file/DocumentFileCompat.kt | 5 +- .../java/com/lazygeniouz/dfc/file/Query.kt | 417 +++++++++++------- .../dfc/resolver/ResolverCompat.kt | 69 +-- .../com/lazygeniouz/dfc/file/QueryTest.kt | 149 +++---- 8 files changed, 392 insertions(+), 280 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39145e3..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 :dfc:testDebugUnitTest + - name: Build + run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug diff --git a/README.md b/README.md index 43749c0..8349692 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ val recentFiles = directory.listFiles( ``` On API 21-25, only `Query.select(...)`, `Query.projection(...)`, `Query.orderByAsc(...)`, and -`Query.orderByDesc(...)` are honored. On API 26+, filters, `Query.limit(...)`, +`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 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 56306f6..fd325a4 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 @@ -20,10 +20,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") @@ -62,7 +62,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 @@ -75,7 +75,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" } @@ -86,7 +86,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, @@ -101,7 +101,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/build.gradle b/build.gradle index e078c4c..90fb789 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 { @@ -41,4 +41,4 @@ allprojects { tasks.register("clean", Delete) { delete rootProject.layout.buildDirectory -} \ No newline at end of file +} 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 f45fba0..60f2779 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -80,12 +80,11 @@ abstract class DocumentFileCompat( * * API support for SAF child-document queries: * - * - API 21-25: only [Query.select], [Query.projection], [Query.orderByAsc], and [Query.orderByDesc] are honored. + * - API 21-25: only [Query.select], [Query.projection], [Query.orderByAsc], and [Query.orderByDesc] are forwarded. * - API 26+: filter queries, [Query.limit], [Query.offset], and [Query.rawSelection] are also forwarded. * - * **NOTE: Unsupported clauses are ignored and logged.** + * **NOTE: Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses.** */ - // 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." diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index a873292..c22b19c 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -13,33 +13,34 @@ import com.lazygeniouz.dfc.file.Query.Companion.rawSelection * * For tree-backed SAF directories: * - * - API 21-25: only [projection], [orderByAsc], and [orderByDesc] are honored. + * - API 21-25: only [projection], [orderByAsc], and [orderByDesc] are forwarded. * - API 26+: filter queries, [limit], [offset], and [rawSelection] are also forwarded. * - * Unsupported clauses are ignored and logged. + * Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses. */ -sealed class Query { - - internal data class Projection(val columns: List) : Query() - - internal data class Sort(val column: String, val descending: Boolean) : Query() - - internal data class Limit(val count: Int) : Query() - - internal data class Offset(val count: Int) : Query() - - internal data class Selection( - val attribute: String, - val operator: Operator, - val values: List, - ) : Query() - - internal data class RawSelection( - val selection: String, - val args: List, - ) : Query() +class Query private constructor( + private val kind: Kind, + private val columns: List, + private val sortColumn: String, + private val sortDescending: Boolean, + private val count: Int, + private val attribute: String, + private val operator: Operator?, + private val values: List, + private val rawSelectionValue: String, + private val rawSelectionArgs: List, +) { + + private enum class Kind { + PROJECTION, + SORT, + LIMIT, + OFFSET, + SELECTION, + RAW_SELECTION, + } - internal enum class Operator { + private enum class Operator { EQUAL, NOT_EQUAL, IN, @@ -55,22 +56,128 @@ sealed class Query { NOT_LIKE, } + @JvmSynthetic + internal fun projectionColumns(): List? { + return if (kind == Kind.PROJECTION) columns else null + } + + @JvmSynthetic + internal fun sortClause(): String? { + return if (kind == Kind.SORT) { + "$sortColumn ${if (sortDescending) "DESC" else "ASC"}" + } else { + null + } + } + + @JvmSynthetic + internal fun limitCount(): Int? { + return if (kind == Kind.LIMIT) count else null + } + + @JvmSynthetic + internal fun offsetCount(): Int? { + return if (kind == Kind.OFFSET) count else null + } + + @JvmSynthetic + internal fun selectionPart(): SelectionPart? { + return if (kind == Kind.SELECTION) toSelectionPart() else null + } + + @JvmSynthetic + internal fun rawSelectionPart(): SelectionPart? { + return if (kind == Kind.RAW_SELECTION) { + SelectionPart("($rawSelectionValue)", rawSelectionArgs) + } else { + null + } + } + + @JvmSynthetic + internal fun describe(): String { + return when (kind) { + Kind.PROJECTION -> "projection" + Kind.SORT -> if (sortDescending) "orderByDesc($sortColumn)" else "orderByAsc($sortColumn)" + Kind.LIMIT -> "limit($count)" + Kind.OFFSET -> "offset($count)" + Kind.SELECTION -> when (operator) { + Operator.EQUAL -> "equal($attribute)" + Operator.NOT_EQUAL -> "notEqual($attribute)" + Operator.IN -> "in($attribute)" + Operator.NOT_IN -> "notIn($attribute)" + Operator.GREATER_THAN -> "greaterThan($attribute)" + Operator.GREATER_THAN_OR_EQUAL -> "greaterThanOrEqual($attribute)" + Operator.LESS_THAN -> "lessThan($attribute)" + Operator.LESS_THAN_OR_EQUAL -> "lessThanOrEqual($attribute)" + Operator.BETWEEN -> "between($attribute)" + Operator.IS_NULL -> "isNull($attribute)" + Operator.IS_NOT_NULL -> "isNotNull($attribute)" + Operator.LIKE -> "like($attribute)" + Operator.NOT_LIKE -> "notLike($attribute)" + null -> "selection" + } + + Kind.RAW_SELECTION -> "rawSelection" + } + } + + private fun toSelectionPart(): SelectionPart { + return when (operator) { + Operator.EQUAL -> SelectionPart("($attribute = ?)", listOf(values.first().toSqlArg())) + Operator.NOT_EQUAL -> SelectionPart( + "($attribute != ?)", + listOf(values.first().toSqlArg()), + ) + + Operator.IN -> buildInSelection(attribute, values) + Operator.NOT_IN -> buildNotInSelection(attribute, values) + + Operator.GREATER_THAN -> + SelectionPart("($attribute > ?)", listOf(values.first().toSqlArg())) + + Operator.GREATER_THAN_OR_EQUAL -> + SelectionPart("($attribute >= ?)", listOf(values.first().toSqlArg())) + + Operator.LESS_THAN -> + SelectionPart("($attribute < ?)", listOf(values.first().toSqlArg())) + + Operator.LESS_THAN_OR_EQUAL -> + SelectionPart("($attribute <= ?)", listOf(values.first().toSqlArg())) + + Operator.BETWEEN -> SelectionPart( + "($attribute BETWEEN ? AND ?)", + listOf(values[0].toSqlArg(), values[1].toSqlArg()), + ) + + Operator.IS_NULL -> SelectionPart("($attribute IS NULL)", emptyList()) + Operator.IS_NOT_NULL -> SelectionPart("($attribute IS NOT NULL)", emptyList()) + Operator.LIKE -> + SelectionPart("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + + Operator.NOT_LIKE -> + SelectionPart("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + + null -> error("Selection query is missing an operator.") + } + } + companion object { /** * Fetch the given columns, plus columns required internally to build results. * - * Honored on API 21+. + * Forwarded on API 21+. */ @JvmStatic fun select(vararg columns: String): Query { - return Projection(columns.toList()) + return projectionQuery(columns.toList()) } /** * Fetch the given columns, plus columns required internally to build results. * - * Honored on API 21+. + * Forwarded on API 21+. */ @Deprecated( message = "Use select(...) instead.", @@ -84,204 +191,199 @@ sealed class Query { /** * Sort ascending by the given column. * - * Honored on API 21+. + * Forwarded on API 21+. */ @JvmStatic fun orderByAsc(column: String): Query { - requireSqlIdentifier(column, "column") - return Sort(column, descending = false) + requireAndroidColumnName(column, "column") + return sortQuery(column, descending = false) } /** * Sort descending by the given column. * - * Honored on API 21+. + * Forwarded on API 21+. */ @JvmStatic fun orderByDesc(column: String): Query { - requireSqlIdentifier(column, "column") - return Sort(column, descending = true) + requireAndroidColumnName(column, "column") + return sortQuery(column, descending = true) } /** * Limit the number of returned child documents. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun limit(count: Int): Query { require(count >= 0) { "limit must be >= 0" } - return Limit(count) + return countQuery(Kind.LIMIT, count) } /** * Skip the first [count] child documents. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun offset(count: Int): Query { require(count >= 0) { "offset must be >= 0" } - return Offset(count) + return countQuery(Kind.OFFSET, count) } /** * Attribute equals [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun equal(attribute: String, value: Any?): Query { - return if (value == null) isNull(attribute) else Selection( - attribute, - Operator.EQUAL, - listOf(value), - ) + return if (value == null) isNull(attribute) + else selection(attribute, Operator.EQUAL, listOf(value)) } /** * Attribute does not equal [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun notEqual(attribute: String, value: Any?): Query { - return if (value == null) isNotNull(attribute) else Selection( - attribute, - Operator.NOT_EQUAL, - listOf(value), - ) + return if (value == null) isNotNull(attribute) + else selection(attribute, Operator.NOT_EQUAL, listOf(value)) } /** * Attribute equals one of [values]. * - * Honored on API 26+. + * 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, Operator.IN, values.toList()) + return selection(attribute, Operator.IN, values.toList()) } /** * Attribute does not equal any of [values]. * - * Honored on API 26+. + * 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, Operator.NOT_IN, values.toList()) + return selection(attribute, Operator.NOT_IN, values.toList()) } /** * Attribute is greater than [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun greaterThan(attribute: String, value: Any): Query { - return Selection(attribute, Operator.GREATER_THAN, listOf(value)) + return selection(attribute, Operator.GREATER_THAN, listOf(value)) } /** * Attribute is greater than or equal to [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun greaterThanOrEqual(attribute: String, value: Any): Query { - return Selection(attribute, Operator.GREATER_THAN_OR_EQUAL, listOf(value)) + return selection(attribute, Operator.GREATER_THAN_OR_EQUAL, listOf(value)) } /** * Attribute is less than [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun lessThan(attribute: String, value: Any): Query { - return Selection(attribute, Operator.LESS_THAN, listOf(value)) + return selection(attribute, Operator.LESS_THAN, listOf(value)) } /** * Attribute is less than or equal to [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun lessThanOrEqual(attribute: String, value: Any): Query { - return Selection(attribute, Operator.LESS_THAN_OR_EQUAL, listOf(value)) + return selection(attribute, Operator.LESS_THAN_OR_EQUAL, listOf(value)) } /** * Attribute is between [start] and [endInclusive]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun between(attribute: String, start: Any, endInclusive: Any): Query { - return Selection(attribute, Operator.BETWEEN, listOf(start, endInclusive)) + return selection(attribute, Operator.BETWEEN, listOf(start, endInclusive)) } /** * Attribute is null. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun isNull(attribute: String): Query { - return Selection(attribute, Operator.IS_NULL, emptyList()) + return selection(attribute, Operator.IS_NULL, emptyList()) } /** * Attribute is not null. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun isNotNull(attribute: String): Query { - return Selection(attribute, Operator.IS_NOT_NULL, emptyList()) + return selection(attribute, Operator.IS_NOT_NULL, emptyList()) } /** * Attribute matches the SQL LIKE [pattern]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun like(attribute: String, pattern: String): Query { - return Selection(attribute, Operator.LIKE, listOf(pattern)) + return selection(attribute, Operator.LIKE, listOf(pattern)) } /** * Attribute does not match the SQL LIKE [pattern]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun notLike(attribute: String, pattern: String): Query { - return Selection(attribute, Operator.NOT_LIKE, listOf(pattern)) + return selection(attribute, Operator.NOT_LIKE, listOf(pattern)) } /** * Pass a raw SQL-style selection expression. * * The selection is forwarded as-is; callers must keep column names trusted. + * Use this for provider-specific expressions or qualified column references. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun rawSelection(selection: String, vararg args: String): Query { require(selection.isNotBlank()) { "selection must not be blank" } - return RawSelection(selection, args.toList()) + return rawSelectionQuery(selection, args.toList()) } /** * Exclude directories. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun filesOnly(): Query { @@ -291,7 +393,7 @@ sealed class Query { /** * Include only directories. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun directoriesOnly(): Query { @@ -301,7 +403,7 @@ sealed class Query { /** * Name equals [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun nameEquals(value: String): Query { @@ -311,7 +413,7 @@ sealed class Query { /** * Name contains [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun nameContains(value: String): Query { @@ -324,7 +426,7 @@ sealed class Query { /** * MIME type equals [value]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun mimeType(value: String): Query { @@ -334,7 +436,7 @@ sealed class Query { /** * MIME type equals one of [values]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun mimeTypeIn(vararg values: String): Query { @@ -344,7 +446,7 @@ sealed class Query { /** * Size is greater than [bytes]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun sizeGreaterThan(bytes: Long): Query { @@ -354,7 +456,7 @@ sealed class Query { /** * Size is less than [bytes]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun sizeLessThan(bytes: Long): Query { @@ -364,7 +466,7 @@ sealed class Query { /** * Last modified time is after [timestampMillis]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun lastModifiedAfter(timestampMillis: Long): Query { @@ -374,13 +476,89 @@ sealed class Query { /** * Last modified time is before [timestampMillis]. * - * Honored on API 26+. + * Forwarded on API 26+. */ @JvmStatic fun lastModifiedBefore(timestampMillis: Long): Query { return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) } + private fun projectionQuery(columns: List): Query { + return Query( + Kind.PROJECTION, + columns, + "", + false, + 0, + "", + null, + emptyList(), + "", + emptyList(), + ) + } + + private fun sortQuery(column: String, descending: Boolean): Query { + return Query( + Kind.SORT, + emptyList(), + column, + descending, + 0, + "", + null, + emptyList(), + "", + emptyList(), + ) + } + + private fun countQuery(kind: Kind, count: Int): Query { + return Query( + kind, + emptyList(), + "", + false, + count, + "", + null, + emptyList(), + "", + emptyList(), + ) + } + + private fun selection(attribute: String, operator: Operator, values: List): Query { + requireAndroidColumnName(attribute, "attribute") + return Query( + Kind.SELECTION, + emptyList(), + "", + false, + 0, + attribute, + operator, + values, + "", + emptyList(), + ) + } + + private fun rawSelectionQuery(selection: String, args: List): Query { + return Query( + Kind.RAW_SELECTION, + emptyList(), + "", + false, + 0, + "", + null, + emptyList(), + selection, + args, + ) + } + private fun escapeLikePattern(value: String): String { return value .replace("\\", "\\\\") @@ -390,11 +568,11 @@ sealed class Query { } } -private val SQL_IDENTIFIER_PATTERN = Regex("[A-Za-z_][A-Za-z0-9_.]*") +private val ANDROID_COLUMN_NAME_PATTERN = Regex("[A-Za-z_][A-Za-z0-9_]*") -private fun requireSqlIdentifier(identifier: String, label: String) { - require(SQL_IDENTIFIER_PATTERN.matches(identifier)) { - "$label must be a simple SQL column name." +private fun requireAndroidColumnName(identifier: String, label: String) { + require(ANDROID_COLUMN_NAME_PATTERN.matches(identifier)) { + "$label must be a simple Android contract column name." } } @@ -414,47 +592,6 @@ internal data class SelectionPart( val args: List, ) -internal fun Query.Selection.toSelectionPart(): SelectionPart { - requireSqlIdentifier(attribute, "attribute") - val attribute = attribute - - return when (operator) { - Query.Operator.EQUAL -> SelectionPart("($attribute = ?)", listOf(values.first().toSqlArg())) - Query.Operator.NOT_EQUAL -> SelectionPart( - "($attribute != ?)", - listOf(values.first().toSqlArg()) - ) - - Query.Operator.IN -> buildInSelection(attribute, values) - Query.Operator.NOT_IN -> buildNotInSelection(attribute, values) - - Query.Operator.GREATER_THAN -> - SelectionPart("($attribute > ?)", listOf(values.first().toSqlArg())) - - Query.Operator.GREATER_THAN_OR_EQUAL -> - SelectionPart("($attribute >= ?)", listOf(values.first().toSqlArg())) - - Query.Operator.LESS_THAN -> - SelectionPart("($attribute < ?)", listOf(values.first().toSqlArg())) - - Query.Operator.LESS_THAN_OR_EQUAL -> - SelectionPart("($attribute <= ?)", listOf(values.first().toSqlArg())) - - Query.Operator.BETWEEN -> SelectionPart( - "($attribute BETWEEN ? AND ?)", - listOf(values[0].toSqlArg(), values[1].toSqlArg()), - ) - - Query.Operator.IS_NULL -> SelectionPart("($attribute IS NULL)", emptyList()) - Query.Operator.IS_NOT_NULL -> SelectionPart("($attribute IS NOT NULL)", emptyList()) - Query.Operator.LIKE -> - SelectionPart("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) - - Query.Operator.NOT_LIKE -> - SelectionPart("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) - } -} - private fun buildInSelection(attribute: String, values: List): SelectionPart { val nonNullValues = values.filterNotNull() val hasNull = values.any { it == null } @@ -498,29 +635,3 @@ private fun Any?.toSqlArg(): String { else -> toString() } } - -internal fun Query.describe(): String { - return when (this) { - is Query.Projection -> "projection" - is Query.Sort -> if (descending) "orderByDesc($column)" else "orderByAsc($column)" - is Query.Limit -> "limit($count)" - is Query.Offset -> "offset($count)" - is Query.Selection -> when (operator) { - Query.Operator.EQUAL -> "equal($attribute)" - Query.Operator.NOT_EQUAL -> "notEqual($attribute)" - Query.Operator.IN -> "in($attribute)" - Query.Operator.NOT_IN -> "notIn($attribute)" - Query.Operator.GREATER_THAN -> "greaterThan($attribute)" - Query.Operator.GREATER_THAN_OR_EQUAL -> "greaterThanOrEqual($attribute)" - Query.Operator.LESS_THAN -> "lessThan($attribute)" - Query.Operator.LESS_THAN_OR_EQUAL -> "lessThanOrEqual($attribute)" - Query.Operator.BETWEEN -> "between($attribute)" - Query.Operator.IS_NULL -> "isNull($attribute)" - Query.Operator.IS_NOT_NULL -> "isNotNull($attribute)" - Query.Operator.LIKE -> "like($attribute)" - Query.Operator.NOT_LIKE -> "notLike($attribute)" - } - - is Query.RawSelection -> "rawSelection" - } -} 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 53a62c6..7ae0af4 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -11,8 +11,6 @@ import android.provider.DocumentsContract.Document import com.lazygeniouz.dfc.file.DocumentFileCompat import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.file.QueryDefaults -import com.lazygeniouz.dfc.file.describe -import com.lazygeniouz.dfc.file.toSelectionPart import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat import com.lazygeniouz.dfc.logger.ErrorLogger @@ -142,7 +140,7 @@ internal object ResolverCompat { ): List { val uri = file.uri val childrenUri = createChildrenUri(uri) - val projectionQueries = queries.filterIsInstance() + val projectionQueries = queries.mapNotNull { it.projectionColumns() } val projection = LinkedHashSet().apply { // Required internally to build child Uris and preserve child document behavior. add(Document.COLUMN_DOCUMENT_ID) @@ -151,7 +149,7 @@ internal object ResolverCompat { if (projectionQueries.isEmpty()) { addAll(QueryDefaults.DEFAULT_PROJECTION) } else { - projectionQueries.forEach { addAll(it.columns) } + projectionQueries.forEach { addAll(it) } } }.toTypedArray() @@ -164,39 +162,46 @@ internal object ResolverCompat { var offset: Int? = null queries.forEach { query -> - when (query) { - is Query.Projection -> Unit - is Query.Sort -> { - sortClauses += "${query.column} ${if (query.descending) "DESC" else "ASC"}" - } + if (query.projectionColumns() != null) return@forEach - is Query.Limit -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) limit = query.count - else ignoredQueries += query - } + val sortClause = query.sortClause() + if (sortClause != null) { + sortClauses += sortClause + return@forEach + } - is Query.Offset -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = query.count - else ignoredQueries += query - } + val limitCount = query.limitCount() + if (limitCount != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) limit = limitCount + else ignoredQueries += query + return@forEach + } - is Query.Selection -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val compiledSelection = query.toSelectionPart() - selectionParts += compiledSelection.selection - selectionArgs += compiledSelection.args - } else { - ignoredQueries += query - } + val offsetCount = query.offsetCount() + if (offsetCount != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = offsetCount + else ignoredQueries += query + return@forEach + } + + val selectionPart = query.selectionPart() + if (selectionPart != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += selectionPart.selection + selectionArgs += selectionPart.args + } else { + ignoredQueries += query } + return@forEach + } - is Query.RawSelection -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - selectionParts += "(${query.selection})" - selectionArgs += query.args - } else { - ignoredQueries += query - } + val rawSelectionPart = query.rawSelectionPart() + if (rawSelectionPart != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += rawSelectionPart.selection + selectionArgs += rawSelectionPart.args + } else { + ignoredQueries += query } } } diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 8069065..06a9307 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -2,7 +2,6 @@ package com.lazygeniouz.dfc.file import android.provider.DocumentsContract.Document import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -10,9 +9,7 @@ class QueryTest { @Test fun `nameContains escapes sql like wildcards`() { - val query = Query.nameContains("100%_done\\ready") as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.nameContains("100%_done\\ready").selectionPart()!! assertEquals( "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", @@ -23,9 +20,8 @@ class QueryTest { @Test fun `in with null adds is null clause`() { - val query = Query.`in`(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection - - val selectionPart = query.toSelectionPart() + 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))", @@ -36,9 +32,8 @@ class QueryTest { @Test fun `notIn with null adds is not null clause`() { - val query = Query.notIn(Document.COLUMN_MIME_TYPE, null, "image/png") as Query.Selection - - val selectionPart = query.toSelectionPart() + 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))", @@ -49,25 +44,24 @@ class QueryTest { @Test fun `equal with null becomes isNull selection`() { - val query = Query.equal(Document.COLUMN_MIME_TYPE, null) as Query.Selection + val selectionPart = Query.equal(Document.COLUMN_MIME_TYPE, null).selectionPart()!! - assertEquals(Query.Operator.IS_NULL, query.operator) - assertTrue(query.values.isEmpty()) + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.selection) + assertTrue(selectionPart.args.isEmpty()) } @Test fun `notEqual with null becomes isNotNull selection`() { - val query = Query.notEqual(Document.COLUMN_MIME_TYPE, null) as Query.Selection + val selectionPart = Query.notEqual(Document.COLUMN_MIME_TYPE, null).selectionPart()!! - assertEquals(Query.Operator.IS_NOT_NULL, query.operator) - assertTrue(query.values.isEmpty()) + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.selection) + assertTrue(selectionPart.args.isEmpty()) } @Test fun `equal compiles to equality selection`() { - val query = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.selection) assertEquals(listOf("report.pdf"), selectionPart.args) @@ -75,9 +69,8 @@ class QueryTest { @Test fun `notEqual compiles to inequality selection`() { - val query = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.selection) assertEquals(listOf("report.pdf"), selectionPart.args) @@ -85,9 +78,7 @@ class QueryTest { @Test fun `greaterThan compiles correctly`() { - val query = Query.greaterThan(Document.COLUMN_SIZE, 1024L) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.greaterThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.selection) assertEquals(listOf("1024"), selectionPart.args) @@ -95,9 +86,8 @@ class QueryTest { @Test fun `greaterThanOrEqual compiles correctly`() { - val query = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! assertEquals("(${Document.COLUMN_SIZE} >= ?)", selectionPart.selection) assertEquals(listOf("1024"), selectionPart.args) @@ -105,9 +95,7 @@ class QueryTest { @Test fun `lessThan compiles correctly`() { - val query = Query.lessThan(Document.COLUMN_SIZE, 1024L) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.lessThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.selection) assertEquals(listOf("1024"), selectionPart.args) @@ -115,9 +103,8 @@ class QueryTest { @Test fun `lessThanOrEqual compiles correctly`() { - val query = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! assertEquals("(${Document.COLUMN_SIZE} <= ?)", selectionPart.selection) assertEquals(listOf("1024"), selectionPart.args) @@ -125,9 +112,7 @@ class QueryTest { @Test fun `between compiles correctly`() { - val query = Query.between(Document.COLUMN_SIZE, 10L, 20L) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.between(Document.COLUMN_SIZE, 10L, 20L).selectionPart()!! assertEquals("(${Document.COLUMN_SIZE} BETWEEN ? AND ?)", selectionPart.selection) assertEquals(listOf("10", "20"), selectionPart.args) @@ -135,9 +120,7 @@ class QueryTest { @Test fun `isNull compiles correctly`() { - val query = Query.isNull(Document.COLUMN_MIME_TYPE) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.isNull(Document.COLUMN_MIME_TYPE).selectionPart()!! assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.selection) assertTrue(selectionPart.args.isEmpty()) @@ -145,9 +128,7 @@ class QueryTest { @Test fun `isNotNull compiles correctly`() { - val query = Query.isNotNull(Document.COLUMN_MIME_TYPE) as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.isNotNull(Document.COLUMN_MIME_TYPE).selectionPart()!! assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.selection) assertTrue(selectionPart.args.isEmpty()) @@ -155,9 +136,7 @@ class QueryTest { @Test fun `like compiles correctly`() { - val query = Query.like(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.like(Document.COLUMN_DISPLAY_NAME, "report%").selectionPart()!! assertEquals( "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", @@ -168,9 +147,8 @@ class QueryTest { @Test fun `notLike compiles correctly`() { - val query = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") as Query.Selection - - val selectionPart = query.toSelectionPart() + val selectionPart = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") + .selectionPart()!! assertEquals( "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')", @@ -181,39 +159,35 @@ class QueryTest { @Test fun `filesOnly maps to mime type not equal directory`() { - val query = Query.filesOnly() as Query.Selection + val selectionPart = Query.filesOnly().selectionPart()!! - assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) - assertEquals(Query.Operator.NOT_EQUAL, query.operator) - assertEquals(listOf(Document.MIME_TYPE_DIR), query.values) + assertEquals("(${Document.COLUMN_MIME_TYPE} != ?)", selectionPart.selection) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.args) } @Test fun `directoriesOnly maps to mime type equal directory`() { - val query = Query.directoriesOnly() as Query.Selection + val selectionPart = Query.directoriesOnly().selectionPart()!! - assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) - assertEquals(Query.Operator.EQUAL, query.operator) - assertEquals(listOf(Document.MIME_TYPE_DIR), query.values) + assertEquals("(${Document.COLUMN_MIME_TYPE} = ?)", selectionPart.selection) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.args) } @Test fun `mimeTypeIn maps to in selection`() { - val query = Query.mimeTypeIn("image/png", "image/jpeg") as Query.Selection + val selectionPart = Query.mimeTypeIn("image/png", "image/jpeg").selectionPart()!! - assertEquals(Document.COLUMN_MIME_TYPE, query.attribute) - assertEquals(Query.Operator.IN, query.operator) - assertEquals(listOf("image/png", "image/jpeg"), query.values) + assertEquals("(${Document.COLUMN_MIME_TYPE} IN (?,?))", selectionPart.selection) + assertEquals(listOf("image/png", "image/jpeg"), selectionPart.args) } @Test fun `select returns projection query`() { val query = Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) - as Query.Projection assertEquals( listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), - query.columns, + query.projectionColumns(), ) } @@ -221,42 +195,53 @@ class QueryTest { fun `projection delegates to select`() { @Suppress("DEPRECATION") val query = Query.projection(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) - as Query.Projection assertEquals( listOf(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), - query.columns, + query.projectionColumns(), ) } @Test fun `orderByAsc returns ascending sort query`() { - val query = Query.orderByAsc(Document.COLUMN_DISPLAY_NAME) as Query.Sort - - assertEquals(Document.COLUMN_DISPLAY_NAME, query.column) - assertFalse(query.descending) + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} ASC", + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) } @Test fun `orderByDesc returns descending sort query`() { - val query = Query.orderByDesc(Document.COLUMN_DISPLAY_NAME) as Query.Sort + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} DESC", + Query.orderByDesc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) + } - assertEquals(Document.COLUMN_DISPLAY_NAME, query.column) - assertTrue(query.descending) + @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`() { - val query = Query.limit(25) as Query.Limit - - assertEquals(25, query.count) + assertEquals(25, Query.limit(25).limitCount()) } @Test fun `offset returns offset query`() { - val query = Query.offset(10) as Query.Offset - - assertEquals(10, query.count) + assertEquals(10, Query.offset(10).offsetCount()) } @Test(expected = IllegalArgumentException::class) @@ -291,9 +276,11 @@ class QueryTest { @Test(expected = IllegalArgumentException::class) fun `selection rejects unsafe attribute name`() { - val query = Query.equal("${Document.COLUMN_DISPLAY_NAME}) OR 1=1 --", "report.pdf") - as Query.Selection + Query.equal("${Document.COLUMN_DISPLAY_NAME}) OR 1=1 --", "report.pdf") + } - query.toSelectionPart() + @Test(expected = IllegalArgumentException::class) + fun `orderByAsc rejects qualified column name`() { + Query.orderByAsc("documents.${Document.COLUMN_DISPLAY_NAME}") } } From d07b534b4d3f0d256294f5b5e14c634366a1f301 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 11:27:12 +0530 Subject: [PATCH 06/36] fix: clean up query release surface. --- build.gradle | 7 ++ .../dfc/file/DocumentFileCompat.kt | 8 ++ .../java/com/lazygeniouz/dfc/file/Query.kt | 66 +++++++--------- .../file/internals/RawDocumentFileCompat.kt | 3 +- .../internals/SingleDocumentFileCompat.kt | 1 + .../file/internals/TreeDocumentFileCompat.kt | 3 +- .../dfc/resolver/ResolverCompat.kt | 11 ++- .../com/lazygeniouz/dfc/file/QueryTest.kt | 76 +++++++++---------- 8 files changed, 90 insertions(+), 85 deletions(-) diff --git a/build.gradle b/build.gradle index 90fb789..9c2471b 100644 --- a/build.gradle +++ b/build.gradle @@ -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/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt index 60f2779..d641e19 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -85,12 +85,20 @@ abstract class DocumentFileCompat( * * **NOTE: Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses.** */ + @JvmSynthetic open fun listFiles(vararg queries: Query): List { throw UnsupportedOperationException( "Queries are only supported for DocumentsProvider-backed tree URIs." ) } + /** + * Java-friendly alias for [listFiles] with [Query] clauses. + */ + open fun queryFiles(vararg queries: Query): List { + return listFiles(*queries) + } + /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. * diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index c22b19c..4a3d9b6 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -81,14 +81,14 @@ class Query private constructor( } @JvmSynthetic - internal fun selectionPart(): SelectionPart? { + internal fun selectionPart(): Pair>? { return if (kind == Kind.SELECTION) toSelectionPart() else null } @JvmSynthetic - internal fun rawSelectionPart(): SelectionPart? { + internal fun rawSelectionPart(): Pair>? { return if (kind == Kind.RAW_SELECTION) { - SelectionPart("($rawSelectionValue)", rawSelectionArgs) + compiledSelection("($rawSelectionValue)", rawSelectionArgs) } else { null } @@ -122,10 +122,10 @@ class Query private constructor( } } - private fun toSelectionPart(): SelectionPart { + private fun toSelectionPart(): Pair> { return when (operator) { - Operator.EQUAL -> SelectionPart("($attribute = ?)", listOf(values.first().toSqlArg())) - Operator.NOT_EQUAL -> SelectionPart( + Operator.EQUAL -> compiledSelection("($attribute = ?)", listOf(values.first().toSqlArg())) + Operator.NOT_EQUAL -> compiledSelection( "($attribute != ?)", listOf(values.first().toSqlArg()), ) @@ -134,29 +134,29 @@ class Query private constructor( Operator.NOT_IN -> buildNotInSelection(attribute, values) Operator.GREATER_THAN -> - SelectionPart("($attribute > ?)", listOf(values.first().toSqlArg())) + compiledSelection("($attribute > ?)", listOf(values.first().toSqlArg())) Operator.GREATER_THAN_OR_EQUAL -> - SelectionPart("($attribute >= ?)", listOf(values.first().toSqlArg())) + compiledSelection("($attribute >= ?)", listOf(values.first().toSqlArg())) Operator.LESS_THAN -> - SelectionPart("($attribute < ?)", listOf(values.first().toSqlArg())) + compiledSelection("($attribute < ?)", listOf(values.first().toSqlArg())) Operator.LESS_THAN_OR_EQUAL -> - SelectionPart("($attribute <= ?)", listOf(values.first().toSqlArg())) + compiledSelection("($attribute <= ?)", listOf(values.first().toSqlArg())) - Operator.BETWEEN -> SelectionPart( + Operator.BETWEEN -> compiledSelection( "($attribute BETWEEN ? AND ?)", listOf(values[0].toSqlArg(), values[1].toSqlArg()), ) - Operator.IS_NULL -> SelectionPart("($attribute IS NULL)", emptyList()) - Operator.IS_NOT_NULL -> SelectionPart("($attribute IS NOT NULL)", emptyList()) + Operator.IS_NULL -> compiledSelection("($attribute IS NULL)", emptyList()) + Operator.IS_NOT_NULL -> compiledSelection("($attribute IS NOT NULL)", emptyList()) Operator.LIKE -> - SelectionPart("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + compiledSelection("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) Operator.NOT_LIKE -> - SelectionPart("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) + compiledSelection("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) null -> error("Selection query is missing an operator.") } @@ -576,58 +576,46 @@ private fun requireAndroidColumnName(identifier: String, label: String) { } } -internal object QueryDefaults { - val DEFAULT_PROJECTION = listOf( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_SIZE, - Document.COLUMN_LAST_MODIFIED, - Document.COLUMN_MIME_TYPE, - Document.COLUMN_FLAGS, - ) -} - -internal data class SelectionPart( - val selection: String, - val args: List, -) - -private fun buildInSelection(attribute: String, values: List): SelectionPart { +private fun buildInSelection(attribute: String, values: List): Pair> { val nonNullValues = values.filterNotNull() val hasNull = values.any { it == null } return when { - nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NULL)", emptyList()) - hasNull -> SelectionPart( + nonNullValues.isEmpty() && hasNull -> compiledSelection("($attribute IS NULL)", emptyList()) + hasNull -> compiledSelection( "(($attribute IN (${nonNullValues.joinToString(",") { "?" }})) OR ($attribute IS NULL))", nonNullValues.map { it.toSqlArg() }, ) - else -> SelectionPart( + else -> compiledSelection( "($attribute IN (${nonNullValues.joinToString(",") { "?" }}))", nonNullValues.map { it.toSqlArg() }, ) } } -private fun buildNotInSelection(attribute: String, values: List): SelectionPart { +private fun buildNotInSelection(attribute: String, values: List): Pair> { val nonNullValues = values.filterNotNull() val hasNull = values.any { it == null } return when { - nonNullValues.isEmpty() && hasNull -> SelectionPart("($attribute IS NOT NULL)", emptyList()) - hasNull -> SelectionPart( + 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 -> SelectionPart( + 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" 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 7755a03..d37dee2 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 @@ -119,6 +119,7 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : * * @throws UnsupportedOperationException */ + @JvmSynthetic override fun listFiles(vararg queries: Query): List { throw UnsupportedOperationException( "Queries are only supported for DocumentsProvider-backed tree URIs." @@ -165,4 +166,4 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return "application/octet-stream" } } -} \ No newline at end of file +} 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 458a8f4..b6db7c3 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 @@ -65,6 +65,7 @@ internal class SingleDocumentFileCompat( * * @throws UnsupportedOperationException */ + @JvmSynthetic override fun listFiles(vararg queries: Query): List { throw UnsupportedOperationException( "Queries are only supported for DocumentsProvider-backed tree URIs." 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 3d39e4d..3401cb3 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 @@ -62,6 +62,7 @@ internal class TreeDocumentFileCompat( return fileController.listFiles() } + @JvmSynthetic override fun listFiles(vararg queries: Query): List { return fileController.listFiles(*queries) } @@ -148,4 +149,4 @@ internal class TreeDocumentFileCompat( return null } } -} \ 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 7ae0af4..6f7730e 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -10,7 +10,6 @@ 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.QueryDefaults import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat import com.lazygeniouz.dfc.logger.ErrorLogger @@ -147,7 +146,7 @@ internal object ResolverCompat { add(Document.COLUMN_MIME_TYPE) if (projectionQueries.isEmpty()) { - addAll(QueryDefaults.DEFAULT_PROJECTION) + addAll(fullProjection) } else { projectionQueries.forEach { addAll(it) } } @@ -187,8 +186,8 @@ internal object ResolverCompat { val selectionPart = query.selectionPart() if (selectionPart != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - selectionParts += selectionPart.selection - selectionArgs += selectionPart.args + selectionParts += selectionPart.first + selectionArgs += selectionPart.second } else { ignoredQueries += query } @@ -198,8 +197,8 @@ internal object ResolverCompat { val rawSelectionPart = query.rawSelectionPart() if (rawSelectionPart != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - selectionParts += rawSelectionPart.selection - selectionArgs += rawSelectionPart.args + selectionParts += rawSelectionPart.first + selectionArgs += rawSelectionPart.second } else { ignoredQueries += query } diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 06a9307..0edf40a 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -13,9 +13,9 @@ class QueryTest { assertEquals( "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", - selectionPart.selection, + selectionPart.first, ) - assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.args) + assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.second) } @Test @@ -25,9 +25,9 @@ class QueryTest { assertEquals( "((${Document.COLUMN_MIME_TYPE} IN (?)) OR (${Document.COLUMN_MIME_TYPE} IS NULL))", - selectionPart.selection, + selectionPart.first, ) - assertEquals(listOf("image/png"), selectionPart.args) + assertEquals(listOf("image/png"), selectionPart.second) } @Test @@ -37,25 +37,25 @@ class QueryTest { assertEquals( "((${Document.COLUMN_MIME_TYPE} NOT IN (?)) AND (${Document.COLUMN_MIME_TYPE} IS NOT NULL))", - selectionPart.selection, + selectionPart.first, ) - assertEquals(listOf("image/png"), selectionPart.args) + 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.selection) - assertTrue(selectionPart.args.isEmpty()) + 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.selection) - assertTrue(selectionPart.args.isEmpty()) + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) } @Test @@ -63,8 +63,8 @@ class QueryTest { val selectionPart = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") .selectionPart()!! - assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.selection) - assertEquals(listOf("report.pdf"), selectionPart.args) + assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.first) + assertEquals(listOf("report.pdf"), selectionPart.second) } @Test @@ -72,16 +72,16 @@ class QueryTest { val selectionPart = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") .selectionPart()!! - assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.selection) - assertEquals(listOf("report.pdf"), selectionPart.args) + 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.selection) - assertEquals(listOf("1024"), selectionPart.args) + assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) } @Test @@ -89,16 +89,16 @@ class QueryTest { val selectionPart = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) .selectionPart()!! - assertEquals("(${Document.COLUMN_SIZE} >= ?)", selectionPart.selection) - assertEquals(listOf("1024"), selectionPart.args) + 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.selection) - assertEquals(listOf("1024"), selectionPart.args) + assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) } @Test @@ -106,32 +106,32 @@ class QueryTest { val selectionPart = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) .selectionPart()!! - assertEquals("(${Document.COLUMN_SIZE} <= ?)", selectionPart.selection) - assertEquals(listOf("1024"), selectionPart.args) + 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.selection) - assertEquals(listOf("10", "20"), selectionPart.args) + assertEquals("(${Document.COLUMN_SIZE} BETWEEN ? AND ?)", selectionPart.first) + assertEquals(listOf("10", "20"), selectionPart.second) } @Test fun `isNull compiles correctly`() { val selectionPart = Query.isNull(Document.COLUMN_MIME_TYPE).selectionPart()!! - assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.selection) - assertTrue(selectionPart.args.isEmpty()) + 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.selection) - assertTrue(selectionPart.args.isEmpty()) + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) } @Test @@ -140,9 +140,9 @@ class QueryTest { assertEquals( "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", - selectionPart.selection, + selectionPart.first, ) - assertEquals(listOf("report%"), selectionPart.args) + assertEquals(listOf("report%"), selectionPart.second) } @Test @@ -152,33 +152,33 @@ class QueryTest { assertEquals( "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')", - selectionPart.selection, + selectionPart.first, ) - assertEquals(listOf("report%"), selectionPart.args) + 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.selection) - assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.args) + 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.selection) - assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.args) + 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.selection) - assertEquals(listOf("image/png", "image/jpeg"), selectionPart.args) + assertEquals("(${Document.COLUMN_MIME_TYPE} IN (?,?))", selectionPart.first) + assertEquals(listOf("image/png", "image/jpeg"), selectionPart.second) } @Test From 44a0f65cfc4ac7d7d5e6a7136540a386b025f7e5 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 11:36:52 +0530 Subject: [PATCH 07/36] chore: update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 96b3b0e8ff5f2f86f7eb24d12ab096e10ba4e647 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 11:40:24 +0530 Subject: [PATCH 08/36] docs: clarify raw file query behavior --- .../dfc/file/internals/RawDocumentFileCompat.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 d37dee2..9e64337 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 @@ -107,15 +107,16 @@ 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() } /** - * Raw file queries are not backed by a DocumentsProvider and therefore don't support - * provider-level query arguments. + * Raw file listings are not backed by a DocumentsProvider, so [Query] clauses + * cannot be applied with provider-level filtering, ordering, or paging semantics. * * @throws UnsupportedOperationException */ From 9d8b13f57d8ac366c41a8d62077991a90a7f7250 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 12:07:44 +0530 Subject: [PATCH 09/36] refactor: simplify query support cleanup --- README.md | 6 +- .../performance/ProjectionPerformance.kt | 69 ---- .../dfc/file/DocumentFileCompat.kt | 7 - .../java/com/lazygeniouz/dfc/file/Query.kt | 297 +++++------------- .../file/internals/RawDocumentFileCompat.kt | 14 - .../internals/SingleDocumentFileCompat.kt | 13 - .../dfc/resolver/ResolverCompat.kt | 15 +- .../com/lazygeniouz/dfc/file/QueryTest.kt | 11 - .../dfc/resolver/ResolverCompatQueryTest.kt | 30 +- 9 files changed, 114 insertions(+), 348 deletions(-) diff --git a/README.md b/README.md index 8349692..97b0004 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,9 @@ val recentFiles = directory.listFiles( ) ``` -On API 21-25, only `Query.select(...)`, `Query.projection(...)`, `Query.orderByAsc(...)`, and -`Query.orderByDesc(...)` are forwarded. On API 26+, filters, `Query.limit(...)`, -`Query.offset(...)`, and `Query.rawSelection(...)` are also forwarded. +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. 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 fd325a4..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 @@ -2,11 +2,9 @@ package com.lazygeniouz.filecompat.example.performance import android.content.Context import android.net.Uri -import android.os.Build import android.provider.DocumentsContract.Document import androidx.documentfile.provider.DocumentFile import com.lazygeniouz.dfc.file.DocumentFileCompat -import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.filecompat.example.performance.Performance.measureTimeSeconds object ProjectionPerformance { @@ -33,14 +31,6 @@ object ProjectionPerformance { results += "=".repeat(48).plus("\n\n") - // Test 5: Projection overload parity vs Query.select - results += testSelectQueryParity(context, uri) + "\n\n" - - // Test 6: Provider query correctness for filesOnly - results += testFilesOnlyQuery(context, uri) + "\n\n" - - results += "=".repeat(48).plus("\n\n") - return results } @@ -129,63 +119,4 @@ object ProjectionPerformance { "DFC.count() = ${dfcCountTime}s\n" + "DFC.listFiles().size = ${dfcListSizeTime}s" } - - private fun testSelectQueryParity(context: Context, uri: Uri): String { - val documentFile = DocumentFileCompat.fromTreeUri(context, uri) - ?: return "Query.select parity:\nFailed to access directory" - - val projection = arrayOf( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_SIZE, - ) - - var projectionCount = 0 - val projectionTime = measureTimeSeconds { - projectionCount = documentFile.listFiles(projection).size - } - - var queryCount = 0 - val queryTime = measureTimeSeconds { - queryCount = documentFile.listFiles( - Query.select( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_DISPLAY_NAME, - Document.COLUMN_SIZE, - ) - ).size - } - - return "Projection overload vs Query.select:\n" + - "Projection count = $projectionCount (${projectionTime}s)\n" + - "Query.select count = $queryCount (${queryTime}s)\n" + - "Match = ${projectionCount == queryCount}" - } - - private fun testFilesOnlyQuery(context: Context, uri: Uri): String { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return "Query.filesOnly correctness:\nSkipped on API < 26 (query filters are ignored)." - } - - val documentFile = DocumentFileCompat.fromTreeUri(context, uri) - ?: return "Query.filesOnly correctness:\nFailed to access directory" - - var providerResult = emptyList() - val providerTime = measureTimeSeconds { - providerResult = documentFile.listFiles(Query.filesOnly()) - } - - var clientSideResult = emptyList() - val clientSideTime = measureTimeSeconds { - clientSideResult = documentFile.listFiles().filter { !it.isDirectory() } - } - - val providerUris = providerResult.map { it.uri }.toSet() - val clientSideUris = clientSideResult.map { it.uri }.toSet() - - return "Query.filesOnly correctness:\n" + - "Provider query count = ${providerResult.size} (${providerTime}s)\n" + - "Client-side filter count = ${clientSideResult.size} (${clientSideTime}s)\n" + - "Match = ${providerUris == clientSideUris}" - } } 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 d641e19..b5fdc95 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -77,13 +77,6 @@ abstract class DocumentFileCompat( * List child documents using [Query] clauses. * * This is only supported for tree-backed directories. - * - * API support for SAF child-document queries: - * - * - API 21-25: only [Query.select], [Query.projection], [Query.orderByAsc], and [Query.orderByDesc] are forwarded. - * - API 26+: filter queries, [Query.limit], [Query.offset], and [Query.rawSelection] are also forwarded. - * - * **NOTE: Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses.** */ @JvmSynthetic open fun listFiles(vararg queries: Query): List { diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index 4a3d9b6..d3e6c97 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -5,7 +5,6 @@ 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.projection import com.lazygeniouz.dfc.file.Query.Companion.rawSelection /** @@ -13,153 +12,54 @@ import com.lazygeniouz.dfc.file.Query.Companion.rawSelection * * For tree-backed SAF directories: * - * - API 21-25: only [projection], [orderByAsc], and [orderByDesc] are forwarded. + * - 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. */ class Query private constructor( - private val kind: Kind, - private val columns: List, - private val sortColumn: String, - private val sortDescending: Boolean, - private val count: Int, - private val attribute: String, - private val operator: Operator?, - private val values: List, - private val rawSelectionValue: String, - private val rawSelectionArgs: List, + private val projectionColumnsValue: List? = null, + private val sortClauseValue: String? = null, + private val limitCountValue: Int? = null, + private val offsetCountValue: Int? = null, + private val selectionPartValue: Pair>? = null, + private val rawSelectionPartValue: Pair>? = null, + private val description: String, ) { - private enum class Kind { - PROJECTION, - SORT, - LIMIT, - OFFSET, - SELECTION, - RAW_SELECTION, - } - - private enum class Operator { - EQUAL, - NOT_EQUAL, - IN, - NOT_IN, - GREATER_THAN, - GREATER_THAN_OR_EQUAL, - LESS_THAN, - LESS_THAN_OR_EQUAL, - BETWEEN, - IS_NULL, - IS_NOT_NULL, - LIKE, - NOT_LIKE, - } - @JvmSynthetic internal fun projectionColumns(): List? { - return if (kind == Kind.PROJECTION) columns else null + return projectionColumnsValue } @JvmSynthetic internal fun sortClause(): String? { - return if (kind == Kind.SORT) { - "$sortColumn ${if (sortDescending) "DESC" else "ASC"}" - } else { - null - } + return sortClauseValue } @JvmSynthetic internal fun limitCount(): Int? { - return if (kind == Kind.LIMIT) count else null + return limitCountValue } @JvmSynthetic internal fun offsetCount(): Int? { - return if (kind == Kind.OFFSET) count else null + return offsetCountValue } @JvmSynthetic internal fun selectionPart(): Pair>? { - return if (kind == Kind.SELECTION) toSelectionPart() else null + return selectionPartValue } @JvmSynthetic internal fun rawSelectionPart(): Pair>? { - return if (kind == Kind.RAW_SELECTION) { - compiledSelection("($rawSelectionValue)", rawSelectionArgs) - } else { - null - } + return rawSelectionPartValue } @JvmSynthetic internal fun describe(): String { - return when (kind) { - Kind.PROJECTION -> "projection" - Kind.SORT -> if (sortDescending) "orderByDesc($sortColumn)" else "orderByAsc($sortColumn)" - Kind.LIMIT -> "limit($count)" - Kind.OFFSET -> "offset($count)" - Kind.SELECTION -> when (operator) { - Operator.EQUAL -> "equal($attribute)" - Operator.NOT_EQUAL -> "notEqual($attribute)" - Operator.IN -> "in($attribute)" - Operator.NOT_IN -> "notIn($attribute)" - Operator.GREATER_THAN -> "greaterThan($attribute)" - Operator.GREATER_THAN_OR_EQUAL -> "greaterThanOrEqual($attribute)" - Operator.LESS_THAN -> "lessThan($attribute)" - Operator.LESS_THAN_OR_EQUAL -> "lessThanOrEqual($attribute)" - Operator.BETWEEN -> "between($attribute)" - Operator.IS_NULL -> "isNull($attribute)" - Operator.IS_NOT_NULL -> "isNotNull($attribute)" - Operator.LIKE -> "like($attribute)" - Operator.NOT_LIKE -> "notLike($attribute)" - null -> "selection" - } - - Kind.RAW_SELECTION -> "rawSelection" - } - } - - private fun toSelectionPart(): Pair> { - return when (operator) { - Operator.EQUAL -> compiledSelection("($attribute = ?)", listOf(values.first().toSqlArg())) - Operator.NOT_EQUAL -> compiledSelection( - "($attribute != ?)", - listOf(values.first().toSqlArg()), - ) - - Operator.IN -> buildInSelection(attribute, values) - Operator.NOT_IN -> buildNotInSelection(attribute, values) - - Operator.GREATER_THAN -> - compiledSelection("($attribute > ?)", listOf(values.first().toSqlArg())) - - Operator.GREATER_THAN_OR_EQUAL -> - compiledSelection("($attribute >= ?)", listOf(values.first().toSqlArg())) - - Operator.LESS_THAN -> - compiledSelection("($attribute < ?)", listOf(values.first().toSqlArg())) - - Operator.LESS_THAN_OR_EQUAL -> - compiledSelection("($attribute <= ?)", listOf(values.first().toSqlArg())) - - Operator.BETWEEN -> compiledSelection( - "($attribute BETWEEN ? AND ?)", - listOf(values[0].toSqlArg(), values[1].toSqlArg()), - ) - - Operator.IS_NULL -> compiledSelection("($attribute IS NULL)", emptyList()) - Operator.IS_NOT_NULL -> compiledSelection("($attribute IS NOT NULL)", emptyList()) - Operator.LIKE -> - compiledSelection("($attribute LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) - - Operator.NOT_LIKE -> - compiledSelection("($attribute NOT LIKE ? ESCAPE '\\')", listOf(values.first().toSqlArg())) - - null -> error("Selection query is missing an operator.") - } + return description } companion object { @@ -171,21 +71,10 @@ class Query private constructor( */ @JvmStatic fun select(vararg columns: String): Query { - return projectionQuery(columns.toList()) - } - - /** - * Fetch the given columns, plus columns required internally to build results. - * - * Forwarded on API 21+. - */ - @Deprecated( - message = "Use select(...) instead.", - replaceWith = ReplaceWith("select(*columns)"), - ) - @JvmStatic - fun projection(vararg columns: String): Query { - return select(*columns) + return Query( + projectionColumnsValue = columns.toList(), + description = "select", + ) } /** @@ -195,7 +84,6 @@ class Query private constructor( */ @JvmStatic fun orderByAsc(column: String): Query { - requireAndroidColumnName(column, "column") return sortQuery(column, descending = false) } @@ -206,7 +94,6 @@ class Query private constructor( */ @JvmStatic fun orderByDesc(column: String): Query { - requireAndroidColumnName(column, "column") return sortQuery(column, descending = true) } @@ -218,7 +105,10 @@ class Query private constructor( @JvmStatic fun limit(count: Int): Query { require(count >= 0) { "limit must be >= 0" } - return countQuery(Kind.LIMIT, count) + return Query( + limitCountValue = count, + description = "limit($count)", + ) } /** @@ -229,7 +119,10 @@ class Query private constructor( @JvmStatic fun offset(count: Int): Query { require(count >= 0) { "offset must be >= 0" } - return countQuery(Kind.OFFSET, count) + return Query( + offsetCountValue = count, + description = "offset($count)", + ) } /** @@ -240,7 +133,9 @@ class Query private constructor( @JvmStatic fun equal(attribute: String, value: Any?): Query { return if (value == null) isNull(attribute) - else selection(attribute, Operator.EQUAL, listOf(value)) + else selection(attribute, "equal($attribute)") { column -> + compiledSelection("($column = ?)", listOf(value.toSqlArg())) + } } /** @@ -251,7 +146,9 @@ class Query private constructor( @JvmStatic fun notEqual(attribute: String, value: Any?): Query { return if (value == null) isNotNull(attribute) - else selection(attribute, Operator.NOT_EQUAL, listOf(value)) + else selection(attribute, "notEqual($attribute)") { column -> + compiledSelection("($column != ?)", listOf(value.toSqlArg())) + } } /** @@ -262,7 +159,9 @@ class Query private constructor( @JvmStatic fun `in`(attribute: String, vararg values: Any?): Query { require(values.isNotEmpty()) { "in requires at least one value" } - return selection(attribute, Operator.IN, values.toList()) + return selection(attribute, "in($attribute)") { column -> + buildInSelection(column, values.toList()) + } } /** @@ -273,7 +172,9 @@ class Query private constructor( @JvmStatic fun notIn(attribute: String, vararg values: Any?): Query { require(values.isNotEmpty()) { "notIn requires at least one value" } - return selection(attribute, Operator.NOT_IN, values.toList()) + return selection(attribute, "notIn($attribute)") { column -> + buildNotInSelection(column, values.toList()) + } } /** @@ -283,7 +184,9 @@ class Query private constructor( */ @JvmStatic fun greaterThan(attribute: String, value: Any): Query { - return selection(attribute, Operator.GREATER_THAN, listOf(value)) + return selection(attribute, "greaterThan($attribute)") { column -> + compiledSelection("($column > ?)", listOf(value.toSqlArg())) + } } /** @@ -293,7 +196,9 @@ class Query private constructor( */ @JvmStatic fun greaterThanOrEqual(attribute: String, value: Any): Query { - return selection(attribute, Operator.GREATER_THAN_OR_EQUAL, listOf(value)) + return selection(attribute, "greaterThanOrEqual($attribute)") { column -> + compiledSelection("($column >= ?)", listOf(value.toSqlArg())) + } } /** @@ -303,7 +208,9 @@ class Query private constructor( */ @JvmStatic fun lessThan(attribute: String, value: Any): Query { - return selection(attribute, Operator.LESS_THAN, listOf(value)) + return selection(attribute, "lessThan($attribute)") { column -> + compiledSelection("($column < ?)", listOf(value.toSqlArg())) + } } /** @@ -313,7 +220,9 @@ class Query private constructor( */ @JvmStatic fun lessThanOrEqual(attribute: String, value: Any): Query { - return selection(attribute, Operator.LESS_THAN_OR_EQUAL, listOf(value)) + return selection(attribute, "lessThanOrEqual($attribute)") { column -> + compiledSelection("($column <= ?)", listOf(value.toSqlArg())) + } } /** @@ -323,7 +232,12 @@ class Query private constructor( */ @JvmStatic fun between(attribute: String, start: Any, endInclusive: Any): Query { - return selection(attribute, Operator.BETWEEN, listOf(start, endInclusive)) + return selection(attribute, "between($attribute)") { column -> + compiledSelection( + "($column BETWEEN ? AND ?)", + listOf(start.toSqlArg(), endInclusive.toSqlArg()), + ) + } } /** @@ -333,7 +247,9 @@ class Query private constructor( */ @JvmStatic fun isNull(attribute: String): Query { - return selection(attribute, Operator.IS_NULL, emptyList()) + return selection(attribute, "isNull($attribute)") { column -> + compiledSelection("($column IS NULL)", emptyList()) + } } /** @@ -343,7 +259,9 @@ class Query private constructor( */ @JvmStatic fun isNotNull(attribute: String): Query { - return selection(attribute, Operator.IS_NOT_NULL, emptyList()) + return selection(attribute, "isNotNull($attribute)") { column -> + compiledSelection("($column IS NOT NULL)", emptyList()) + } } /** @@ -353,7 +271,9 @@ class Query private constructor( */ @JvmStatic fun like(attribute: String, pattern: String): Query { - return selection(attribute, Operator.LIKE, listOf(pattern)) + return selection(attribute, "like($attribute)") { column -> + compiledSelection("($column LIKE ? ESCAPE '\\')", listOf(pattern)) + } } /** @@ -363,7 +283,9 @@ class Query private constructor( */ @JvmStatic fun notLike(attribute: String, pattern: String): Query { - return selection(attribute, Operator.NOT_LIKE, listOf(pattern)) + return selection(attribute, "notLike($attribute)") { column -> + compiledSelection("($column NOT LIKE ? ESCAPE '\\')", listOf(pattern)) + } } /** @@ -377,7 +299,10 @@ class Query private constructor( @JvmStatic fun rawSelection(selection: String, vararg args: String): Query { require(selection.isNotBlank()) { "selection must not be blank" } - return rawSelectionQuery(selection, args.toList()) + return Query( + rawSelectionPartValue = compiledSelection("($selection)", args.toList()), + description = "rawSelection", + ) } /** @@ -483,79 +408,23 @@ class Query private constructor( return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) } - private fun projectionQuery(columns: List): Query { - return Query( - Kind.PROJECTION, - columns, - "", - false, - 0, - "", - null, - emptyList(), - "", - emptyList(), - ) - } - private fun sortQuery(column: String, descending: Boolean): Query { + requireAndroidColumnName(column, "column") return Query( - Kind.SORT, - emptyList(), - column, - descending, - 0, - "", - null, - emptyList(), - "", - emptyList(), - ) - } - - private fun countQuery(kind: Kind, count: Int): Query { - return Query( - kind, - emptyList(), - "", - false, - count, - "", - null, - emptyList(), - "", - emptyList(), + sortClauseValue = "$column ${if (descending) "DESC" else "ASC"}", + description = if (descending) "orderByDesc($column)" else "orderByAsc($column)", ) } - private fun selection(attribute: String, operator: Operator, values: List): Query { + private fun selection( + attribute: String, + description: String, + build: (String) -> Pair>, + ): Query { requireAndroidColumnName(attribute, "attribute") return Query( - Kind.SELECTION, - emptyList(), - "", - false, - 0, - attribute, - operator, - values, - "", - emptyList(), - ) - } - - private fun rawSelectionQuery(selection: String, args: List): Query { - return Query( - Kind.RAW_SELECTION, - emptyList(), - "", - false, - 0, - "", - null, - emptyList(), - selection, - args, + selectionPartValue = build(attribute), + description = description, ) } 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 9e64337..4b20bf9 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 @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import com.lazygeniouz.dfc.file.DocumentFileCompat -import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.logger.ErrorLogger.logError import java.io.File @@ -114,19 +113,6 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return listFiles() } - /** - * Raw file listings are not backed by a DocumentsProvider, so [Query] clauses - * cannot be applied with provider-level filtering, ordering, or paging semantics. - * - * @throws UnsupportedOperationException - */ - @JvmSynthetic - override fun listFiles(vararg queries: Query): List { - throw UnsupportedOperationException( - "Queries are only supported for DocumentsProvider-backed tree URIs." - ) - } - /** * This will return the children count in the directory. * 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 b6db7c3..9e44982 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 @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import android.provider.DocumentsContract import com.lazygeniouz.dfc.file.DocumentFileCompat -import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -60,18 +59,6 @@ internal class SingleDocumentFileCompat( return listFiles() } - /** - * Single document Uris don't have children, so queries are not applicable. - * - * @throws UnsupportedOperationException - */ - @JvmSynthetic - override fun listFiles(vararg queries: Query): List { - throw UnsupportedOperationException( - "Queries are only supported for DocumentsProvider-backed tree URIs." - ) - } - /** * No [listFiles], no children, no count. * 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 6f7730e..95fa0e6 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -249,20 +249,7 @@ internal object ResolverCompat { * Get [Cursor] from [ContentResolver.query] with given [projection] on a given [uri]. */ internal fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { - return try { - context.contentResolver.query( - uri, projection, null, null, null - ) - } 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 - } + return getCursor(context, uri, projection, null, null, null, null) } /** diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 0edf40a..3acfb32 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -191,17 +191,6 @@ class QueryTest { ) } - @Test - fun `projection delegates to select`() { - @Suppress("DEPRECATION") - val query = Query.projection(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( diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 5849202..e9e85c8 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -59,7 +59,7 @@ class ResolverCompatQueryTest { } @Test - fun `query select keeps internal projection and child document types`() { + fun `query forwards api 26 bundle arguments`() { val context = RuntimeEnvironment.getApplication() val root = TreeDocumentFileCompat( context = context, @@ -92,6 +92,32 @@ class ResolverCompatQueryTest { 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" } @@ -99,13 +125,11 @@ class ResolverCompatQueryTest { assertTrue(file.isFile()) assertFalse(file.isDirectory()) assertEquals("text/plain", file.getType()) - assertEquals("SingleDocumentFileCompat", file.javaClass.simpleName) assertSame(root, file.parentFile) assertTrue(directory.isDirectory()) assertFalse(directory.isFile()) assertNull(directory.getType()) - assertEquals("TreeDocumentFileCompat", directory.javaClass.simpleName) assertSame(root, directory.parentFile) } From 1986f9a70cca9619466743b40223265c5ab4c843 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 12:17:07 +0530 Subject: [PATCH 10/36] chore: bump sample app dependencies --- app/build.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 4409bfc..617470f 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" @@ -39,7 +39,7 @@ dependencies { // implementation "com.lazygeniouz:dfc:1.3" 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" -} \ No newline at end of file + implementation "com.google.android.material:material:1.14.0" +} From bb666333f70c611aac5499dece53fc4747fe4a54 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 13:45:50 +0530 Subject: [PATCH 11/36] fix: keep query listing under listFiles --- app/build.gradle | 2 +- .../dfc/controller/DocumentController.kt | 4 +- .../dfc/file/DocumentFileCompat.kt | 8 - .../java/com/lazygeniouz/dfc/file/Query.kt | 223 ++++++++++-------- .../file/internals/TreeDocumentFileCompat.kt | 1 - .../dfc/resolver/ResolverCompat.kt | 20 +- .../file/DocumentFileCompatJavaApiTest.java | 47 ++++ .../com/lazygeniouz/dfc/file/QueryTest.kt | 20 ++ 8 files changed, 203 insertions(+), 122 deletions(-) create mode 100644 dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java diff --git a/app/build.gradle b/app/build.gradle index 617470f..0ac6e1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,7 +36,7 @@ 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.13.0" 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 0485ed5..41d301b 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt @@ -46,7 +46,7 @@ internal class DocumentController( internal fun listFiles(vararg queries: Query): List { return if (!isDirectory()) { throw UnsupportedOperationException("Selected document is not a Directory.") - } else ResolverCompat.queryFiles(context, fileCompat, *queries) + } else ResolverCompat.listFiles(context, fileCompat, *queries) } /** @@ -159,4 +159,4 @@ internal class DocumentController( internal fun delete(): Boolean { return ResolverCompat.deleteDocument(context, fileUri) } -} \ No newline at end of file +} 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 b5fdc95..0e0c7fe 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -78,20 +78,12 @@ abstract class DocumentFileCompat( * * This is only supported for tree-backed directories. */ - @JvmSynthetic open fun listFiles(vararg queries: Query): List { throw UnsupportedOperationException( "Queries are only supported for DocumentsProvider-backed tree URIs." ) } - /** - * Java-friendly alias for [listFiles] with [Query] clauses. - */ - open fun queryFiles(vararg queries: Query): List { - return listFiles(*queries) - } - /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. * diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index d3e6c97..4fb5ea2 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -17,50 +17,7 @@ import com.lazygeniouz.dfc.file.Query.Companion.rawSelection * * Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses. */ -class Query private constructor( - private val projectionColumnsValue: List? = null, - private val sortClauseValue: String? = null, - private val limitCountValue: Int? = null, - private val offsetCountValue: Int? = null, - private val selectionPartValue: Pair>? = null, - private val rawSelectionPartValue: Pair>? = null, - private val description: String, -) { - - @JvmSynthetic - internal fun projectionColumns(): List? { - return projectionColumnsValue - } - - @JvmSynthetic - internal fun sortClause(): String? { - return sortClauseValue - } - - @JvmSynthetic - internal fun limitCount(): Int? { - return limitCountValue - } - - @JvmSynthetic - internal fun offsetCount(): Int? { - return offsetCountValue - } - - @JvmSynthetic - internal fun selectionPart(): Pair>? { - return selectionPartValue - } - - @JvmSynthetic - internal fun rawSelectionPart(): Pair>? { - return rawSelectionPartValue - } - - @JvmSynthetic - internal fun describe(): String { - return description - } +sealed class Query private constructor() { companion object { @@ -71,7 +28,7 @@ class Query private constructor( */ @JvmStatic fun select(vararg columns: String): Query { - return Query( + return QuerySpec( projectionColumnsValue = columns.toList(), description = "select", ) @@ -105,7 +62,7 @@ class Query private constructor( @JvmStatic fun limit(count: Int): Query { require(count >= 0) { "limit must be >= 0" } - return Query( + return QuerySpec( limitCountValue = count, description = "limit($count)", ) @@ -119,7 +76,7 @@ class Query private constructor( @JvmStatic fun offset(count: Int): Query { require(count >= 0) { "offset must be >= 0" } - return Query( + return QuerySpec( offsetCountValue = count, description = "offset($count)", ) @@ -299,7 +256,7 @@ class Query private constructor( @JvmStatic fun rawSelection(selection: String, vararg args: String): Query { require(selection.isNotBlank()) { "selection must not be blank" } - return Query( + return QuerySpec( rawSelectionPartValue = compiledSelection("($selection)", args.toList()), description = "rawSelection", ) @@ -408,9 +365,48 @@ class Query private constructor( 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 Query( + return QuerySpec( sortClauseValue = "$column ${if (descending) "DESC" else "ASC"}", description = if (descending) "orderByDesc($column)" else "orderByAsc($column)", ) @@ -422,7 +418,7 @@ class Query private constructor( build: (String) -> Pair>, ): Query { requireAndroidColumnName(attribute, "attribute") - return Query( + return QuerySpec( selectionPartValue = build(attribute), description = description, ) @@ -434,61 +430,88 @@ class Query private constructor( .replace("%", "\\%") .replace("_", "\\_") } - } -} -private val ANDROID_COLUMN_NAME_PATTERN = Regex("[A-Za-z_][A-Za-z0-9_]*") + private val androidColumnNamePattern = Regex("[A-Za-z_][A-Za-z0-9_]*") -private fun requireAndroidColumnName(identifier: String, label: String) { - require(ANDROID_COLUMN_NAME_PATTERN.matches(identifier)) { - "$label must be a simple Android contract column name." - } -} + 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 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(), + ) -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() }, - ) - } -} + hasNull -> compiledSelection( + "(($attribute IN (${nonNullValues.joinToString(",") { "?" }})) OR ($attribute IS NULL))", + nonNullValues.map { it.toSqlArg() }, + ) -private fun compiledSelection(selection: String, args: List): Pair> { - return selection to args -} + 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(), + ) -private fun Any?.toSqlArg(): String { - return when (this) { - null -> "null" - is Boolean -> if (this) "1" else "0" - else -> toString() + 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 Boolean -> if (this) "1" else "0" + else -> toString() + } + } } + + 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/TreeDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt index 3401cb3..a9f8cca 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 @@ -62,7 +62,6 @@ internal class TreeDocumentFileCompat( return fileController.listFiles() } - @JvmSynthetic override fun listFiles(vararg queries: Query): List { return fileController.listFiles(*queries) } 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 95fa0e6..f0377cf 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -125,21 +125,21 @@ internal object ResolverCompat { file: DocumentFileCompat, projection: Array = fullProjection, ): List { - return queryFiles(context, file, Query.select(*projection)) + return listFiles(context, file, Query.select(*projection)) } /** * Queries the ContentResolver using provider-level query arguments and builds * a list of [DocumentFileCompat]. */ - internal fun queryFiles( + internal fun listFiles( context: Context, file: DocumentFileCompat, vararg queries: Query, ): List { val uri = file.uri val childrenUri = createChildrenUri(uri) - val projectionQueries = queries.mapNotNull { it.projectionColumns() } + 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) @@ -161,29 +161,29 @@ internal object ResolverCompat { var offset: Int? = null queries.forEach { query -> - if (query.projectionColumns() != null) return@forEach + if (Query.projectionColumns(query) != null) return@forEach - val sortClause = query.sortClause() + val sortClause = Query.sortClause(query) if (sortClause != null) { sortClauses += sortClause return@forEach } - val limitCount = query.limitCount() + 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() + 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() + val selectionPart = Query.selectionPart(query) if (selectionPart != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { selectionParts += selectionPart.first @@ -194,7 +194,7 @@ internal object ResolverCompat { return@forEach } - val rawSelectionPart = query.rawSelectionPart() + val rawSelectionPart = Query.rawSelectionPart(query) if (rawSelectionPart != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { selectionParts += rawSelectionPart.first @@ -296,7 +296,7 @@ internal object ResolverCompat { append("Ignored unsupported queries on API ") append(Build.VERSION.SDK_INT) append(": ") - append(ignoredQueries.joinToString { it.describe() }) + append(ignoredQueries.joinToString { Query.describe(it) }) append(". SAF child-document filtering, limit, and offset require API 26+.") } ) 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..1e9fd7c --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -0,0 +1,47 @@ +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.limit(100), + }; + + assertEquals(3, 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 }) + ) + ); + } +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 3acfb32..ea44ebc 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -273,3 +273,23 @@ class QueryTest { Query.orderByAsc("documents.${Document.COLUMN_DISPLAY_NAME}") } } + +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) +} From b8adf7006d0e4f81a2a4181158fde90782866127 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 14:07:01 +0530 Subject: [PATCH 12/36] docs: add query usage tips --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 97b0004..ba115cb 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,16 @@ are forwarded. On API 26+, filters, `Query.limit(...)`, `Query.offset(...)`, and 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 The sample app includes simple comparisons against AndroidX `DocumentFile`. Results depend on the From 032076ed5b3062425a7211c94ed936cd964ba3f0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 14:16:53 +0530 Subject: [PATCH 13/36] chore: restore eof style --- README.md | 2 +- app/build.gradle | 2 +- app/src/main/res/layout/activity_main.xml | 2 +- build.gradle | 2 +- dfc/build.gradle | 2 +- .../java/com/lazygeniouz/dfc/controller/DocumentController.kt | 2 +- .../main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt | 2 +- .../com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt | 2 +- .../lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt | 2 +- .../lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt | 2 +- dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt | 2 +- .../main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ba115cb..af0b473 100644 --- a/README.md +++ b/README.md @@ -133,4 +133,4 @@ file access less painful. Create an issue if you run into a problem or have a suggestion. PRs are appreciated too, especially when they include a clear behavior change. -And finally, if this saves you some time, a star on the repository would be appreciated. +And finally, if this saves you some time, a star on the repository would be appreciated. \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 0ac6e1a..35b95ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,4 +42,4 @@ dependencies { implementation "androidx.activity:activity-ktx:1.13.0" implementation "androidx.documentfile:documentfile:1.1.0" implementation "com.google.android.material:material:1.14.0" -} +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 38ff10f..a57785f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -53,4 +53,4 @@ android:paddingVertical="12dp" android:text="@string/test_custom_projections" /> - + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9c2471b..6f6e741 100644 --- a/build.gradle +++ b/build.gradle @@ -48,4 +48,4 @@ allprojects { tasks.register("clean", Delete) { delete rootProject.layout.buildDirectory -} +} \ No newline at end of file diff --git a/dfc/build.gradle b/dfc/build.gradle index 01e3398..3f7bc38 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -29,4 +29,4 @@ android { 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 41d301b..0c39acb 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt @@ -159,4 +159,4 @@ internal class DocumentController( internal fun delete(): Boolean { return ResolverCompat.deleteDocument(context, fileUri) } -} +} \ No newline at end of file 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 0e0c7fe..a7bc763 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -265,4 +265,4 @@ abstract class DocumentFileCompat( return paths.size >= 2 && "tree" == paths[0] } } -} +} \ No newline at end of file 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 4b20bf9..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 @@ -153,4 +153,4 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return "application/octet-stream" } } -} +} \ No newline at end of file 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 9e44982..c2552c7 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 @@ -132,4 +132,4 @@ internal class SingleDocumentFileCompat( return null } } -} +} \ No newline at end of file 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 a9f8cca..3d39e4d 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 @@ -148,4 +148,4 @@ internal class TreeDocumentFileCompat( return null } } -} +} \ No newline at end of file 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 ee46f31..7bca777 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt @@ -20,4 +20,4 @@ object ErrorLogger { 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 f0377cf..46033f5 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -380,4 +380,4 @@ internal object ResolverCompat { uri, DocumentsContract.getDocumentId(uri) ) } -} +} \ No newline at end of file From d9262ee306abd84a6dbe2fc89eb746b3c715ea00 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 14:28:58 +0530 Subject: [PATCH 14/36] fix: require mime type for query children --- .../java/com/lazygeniouz/dfc/file/Query.kt | 3 +- .../dfc/resolver/ResolverCompat.kt | 9 +++- .../dfc/resolver/ResolverCompatQueryTest.kt | 47 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index 4fb5ea2..7006e7a 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -248,7 +248,8 @@ sealed class Query private constructor() { /** * Pass a raw SQL-style selection expression. * - * The selection is forwarded as-is; callers must keep column names trusted. + * 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+. 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 46033f5..569fcb4 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -334,6 +334,13 @@ internal object ResolverCompat { 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()) { @@ -343,7 +350,7 @@ internal object ResolverCompat { val documentName = getStringOrDefault(cursor, nameIndex) val documentSize = getLongOrDefault(cursor, sizeIndex) val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L) - val documentMimeType = getStringOrDefault(cursor, mimeIndex) + val documentMimeType = cursor.getString(mimeIndex) ?: continue /** * Default flags to 0 (no capabilities) when not included. diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index e9e85c8..b54714b 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -187,6 +187,50 @@ class ResolverCompatQueryTest { 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)) + + 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 `query listFiles rejects non-directory tree documents`() { val context = RuntimeEnvironment.getApplication() @@ -248,6 +292,8 @@ class ResolverCompatQueryTest { var omitDocumentIdColumn: Boolean = false + var omitMimeTypeColumn: Boolean = false + private val documents = listOf( TestDocument( id = ROOT_ID, @@ -319,6 +365,7 @@ class ResolverCompatQueryTest { ): 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) }) From ebcaabd717005280ad25c1305d677c3e35be57b4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 14:52:28 +0530 Subject: [PATCH 15/36] fix: validate query projection columns --- dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt | 1 + dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index 7006e7a..a01d142 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -28,6 +28,7 @@ sealed class Query private constructor() { */ @JvmStatic fun select(vararg columns: String): Query { + columns.forEach { column -> requireAndroidColumnName(column, "column") } return QuerySpec( projectionColumnsValue = columns.toList(), description = "select", diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index ea44ebc..3e19d8f 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -263,6 +263,11 @@ class QueryTest { 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") From c7eeb5fd6092ecfd5fbc300a2f84a5bf1b648406 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 15:20:01 +0530 Subject: [PATCH 16/36] fix: harden query cursor handling --- .../java/com/lazygeniouz/dfc/file/Query.kt | 1 + .../dfc/resolver/ResolverCompat.kt | 148 ++++++++++-------- .../com/lazygeniouz/dfc/file/QueryTest.kt | 5 + .../dfc/resolver/ResolverCompatQueryTest.kt | 64 +++++++- 4 files changed, 147 insertions(+), 71 deletions(-) diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index a01d142..c18d388 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -28,6 +28,7 @@ sealed class Query private constructor() { */ @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(), 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 569fcb4..2d6f032 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -96,11 +96,16 @@ 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 + return try { + getCursor( + context, + childrenUri, + iconProjection + )?.use { cursor -> cursor.count } ?: 0 + } catch (exception: Exception) { + ErrorLogger.logError("Exception while counting child documents", exception) + 0 + } } /** @@ -310,75 +315,80 @@ internal object ResolverCompat { ): List { val listOfDocuments = arrayListOf() - 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) + 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) - while (cursor.moveToNext()) { - val documentId = cursor.getString(idIndex) ?: continue - val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) + 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 documentName = getStringOrDefault(cursor, nameIndex) - val documentSize = getLongOrDefault(cursor, sizeIndex) - val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L) - val documentMimeType = cursor.getString(mimeIndex) ?: continue + 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() + } - /** - * 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 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) + } } - } - return listOfDocuments + listOfDocuments + } catch (exception: Exception) { + ErrorLogger.logError("Exception while building child document list", exception) + emptyList() + } } // Make children uri for query. diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 3e19d8f..5744e8b 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -253,6 +253,11 @@ class QueryTest { 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(" ") diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index b54714b..c55d333 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -4,6 +4,7 @@ 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 @@ -231,6 +232,38 @@ class ResolverCompatQueryTest { assertEquals(2, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_OFFSET)) } + @Test + fun `count returns zero when child cursor count 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, + ) + provider.throwOnChildCursorCount = true + + assertEquals(0, root.count()) + } + + @Test + fun `query 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, + ) + provider.throwOnChildCursorGetString = true + + val children = root.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + + assertTrue(children.isEmpty()) + } + @Test fun `query listFiles rejects non-directory tree documents`() { val context = RuntimeEnvironment.getApplication() @@ -294,6 +327,10 @@ class ResolverCompatQueryTest { var omitMimeTypeColumn: Boolean = false + var throwOnChildCursorCount: Boolean = false + + var throwOnChildCursorGetString: Boolean = false + private val documents = listOf( TestDocument( id = ROOT_ID, @@ -338,7 +375,7 @@ class ResolverCompatQueryTest { lastLegacySelection = null lastLegacySelectionArgs = null lastLegacySortOrder = sortOrder - return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + return childCursorOf(projection, documents.filterNot { it.id == ROOT_ID }) } override fun queryChildDocuments( @@ -348,7 +385,7 @@ class ResolverCompatQueryTest { ): Cursor { lastChildProjection = projection?.copyProjection() lastQueryArgs = queryArgs?.let(::Bundle) - return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + return childCursorOf(projection, documents.filterNot { it.id == ROOT_ID }) } override fun openDocument( @@ -373,6 +410,29 @@ class ResolverCompatQueryTest { } } + private fun childCursorOf( + projection: Array?, + documents: List, + ): Cursor { + val cursor = cursorOf(projection, documents) + if (!throwOnChildCursorCount && !throwOnChildCursorGetString) return cursor + + return object : CursorWrapper(cursor) { + + override fun getCount(): Int { + if (throwOnChildCursorCount) throw IllegalStateException("count failed") + return super.getCount() + } + + override fun getString(columnIndex: Int): String? { + if (throwOnChildCursorGetString) { + throw IllegalStateException("getString failed") + } + return super.getString(columnIndex) + } + } + } + private data class TestDocument( val id: String, val name: String, From b1738efa5e534e0ab02f04e8c89786986c1dc862 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 16:39:54 +0530 Subject: [PATCH 17/36] fix: harden document cursor reads --- .../internals/SingleDocumentFileCompat.kt | 46 ++++--- .../file/internals/TreeDocumentFileCompat.kt | 46 ++++--- .../dfc/resolver/ResolverCompat.kt | 18 ++- .../internals/DocumentFileCompatMakeTest.kt | 62 +++++++++ .../dfc/resolver/ResolverCompatQueryTest.kt | 121 +++++++++++------- 5 files changed, 212 insertions(+), 81 deletions(-) create mode 100644 dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt 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..f089689 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 @@ -1,9 +1,11 @@ package com.lazygeniouz.dfc.file.internals import android.content.Context +import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.logger.ErrorLogger import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -113,23 +115,37 @@ 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( - context, self, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags - ) - } - } + ?.let { cursor -> return makeFromCursor(context, self, cursor) } return null } + + @JvmSynthetic + internal fun makeFromCursor( + context: Context, + self: Uri, + cursor: Cursor, + ): SingleDocumentFileCompat? { + 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() + + SingleDocumentFileCompat( + context, self, + documentName, documentSize, + documentLastModified, documentMimeType, documentFlags + ) + } + } catch (exception: Exception) { + ErrorLogger.logError("Exception while building a single document file", exception) + null + } + } } } \ No newline at end of file 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 3d39e4d..e359113 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 @@ -1,11 +1,13 @@ package com.lazygeniouz.dfc.file.internals import android.content.Context +import android.database.Cursor 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.logger.ErrorLogger import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -129,23 +131,37 @@ 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( - context, treeUri, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags - ) - } - } + ?.let { cursor -> return makeFromCursor(context, treeUri, cursor) } return null } + + @JvmSynthetic + internal fun makeFromCursor( + context: Context, + treeUri: Uri, + cursor: Cursor, + ): TreeDocumentFileCompat? { + 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() + + TreeDocumentFileCompat( + context, treeUri, + documentName, documentSize, + documentLastModified, documentMimeType, documentFlags + ) + } + } catch (exception: Exception) { + ErrorLogger.logError("Exception while building a tree document file", exception) + null + } + } } } \ 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 2d6f032..1b03d1c 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -96,12 +96,14 @@ internal object ResolverCompat { */ internal fun count(context: Context, uri: Uri): Int { val childrenUri = createChildrenUri(uri) + val cursor = getCursor(context, childrenUri, iconProjection) ?: return 0 + return count(cursor) + } + + @JvmSynthetic + internal fun count(cursor: Cursor): Int { return try { - getCursor( - context, - childrenUri, - iconProjection - )?.use { cursor -> cursor.count } ?: 0 + cursor.use { it.count } } catch (exception: Exception) { ErrorLogger.logError("Exception while counting child documents", exception) 0 @@ -130,7 +132,8 @@ internal object ResolverCompat { file: DocumentFileCompat, projection: Array = fullProjection, ): List { - return listFiles(context, file, Query.select(*projection)) + val effectiveProjection = projection.ifEmpty { fullProjection } + return listFiles(context, file, Query.select(*effectiveProjection)) } /** @@ -307,7 +310,8 @@ internal object ResolverCompat { ) } - private fun buildDocumentList( + @JvmSynthetic + internal fun buildDocumentList( context: Context, file: DocumentFileCompat, treeUri: Uri, 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..64a3bfc --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt @@ -0,0 +1,62 @@ +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.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(SingleDocumentFileCompat.makeFromCursor(context, uri, cursor)) + } + + @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(TreeDocumentFileCompat.makeFromCursor(context, uri, cursor)) + } + + 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 index c55d333..9d753f8 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -171,6 +171,34 @@ class ResolverCompatQueryTest { 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() @@ -183,7 +211,10 @@ class ResolverCompatQueryTest { ) provider.omitDocumentIdColumn = true - val children = root.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) assertTrue(children.isEmpty()) } @@ -200,7 +231,10 @@ class ResolverCompatQueryTest { ) provider.omitMimeTypeColumn = true - val children = root.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) assertTrue(children.isEmpty()) } @@ -234,21 +268,13 @@ class ResolverCompatQueryTest { @Test fun `count returns zero when child cursor count 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, - ) - provider.throwOnChildCursorCount = true + val cursor = throwingCursor(childCursor(), throwOnCount = true) - assertEquals(0, root.count()) + assertEquals(0, ResolverCompat.count(cursor)) } @Test - fun `query returns empty list when child cursor iteration throws`() { + fun `build document list returns empty list when child cursor iteration throws`() { val context = RuntimeEnvironment.getApplication() val root = TreeDocumentFileCompat( context = context, @@ -257,9 +283,9 @@ class ResolverCompatQueryTest { documentMimeType = Document.MIME_TYPE_DIR, documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, ) - provider.throwOnChildCursorGetString = true + val cursor = throwingCursor(childCursor(), throwOnGetString = true) - val children = root.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + val children = ResolverCompat.buildDocumentList(context, root, root.uri, cursor) assertTrue(children.isEmpty()) } @@ -327,10 +353,6 @@ class ResolverCompatQueryTest { var omitMimeTypeColumn: Boolean = false - var throwOnChildCursorCount: Boolean = false - - var throwOnChildCursorGetString: Boolean = false - private val documents = listOf( TestDocument( id = ROOT_ID, @@ -375,7 +397,7 @@ class ResolverCompatQueryTest { lastLegacySelection = null lastLegacySelectionArgs = null lastLegacySortOrder = sortOrder - return childCursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) } override fun queryChildDocuments( @@ -385,7 +407,7 @@ class ResolverCompatQueryTest { ): Cursor { lastChildProjection = projection?.copyProjection() lastQueryArgs = queryArgs?.let(::Bundle) - return childCursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) } override fun openDocument( @@ -410,29 +432,6 @@ class ResolverCompatQueryTest { } } - private fun childCursorOf( - projection: Array?, - documents: List, - ): Cursor { - val cursor = cursorOf(projection, documents) - if (!throwOnChildCursorCount && !throwOnChildCursorGetString) return cursor - - return object : CursorWrapper(cursor) { - - override fun getCount(): Int { - if (throwOnChildCursorCount) throw IllegalStateException("count failed") - return super.getCount() - } - - override fun getString(columnIndex: Int): String? { - if (throwOnChildCursorGetString) { - throw IllegalStateException("getString failed") - } - return super.getString(columnIndex) - } - } - } - private data class TestDocument( val id: String, val name: String, @@ -474,3 +473,37 @@ class ResolverCompatQueryTest { } } } + +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) + } + } +} From 4476d354220b6b7c551b4baca946eff2a1556de5 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 16:47:07 +0530 Subject: [PATCH 18/36] refactor: share document cursor parsing --- .../dfc/file/DocumentFileCompat.kt | 38 ++++++++++++++++ .../internals/SingleDocumentFileCompat.kt | 43 ++++++------------- .../file/internals/TreeDocumentFileCompat.kt | 43 ++++++------------- .../file/DocumentFileCompatJavaApiTest.java | 16 +++++++ .../internals/DocumentFileCompatMakeTest.kt | 13 +++++- 5 files changed, 89 insertions(+), 64 deletions(-) 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 a7bc763..e1a1ae5 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 /** @@ -264,5 +266,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/internals/SingleDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt index f089689..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 @@ -1,11 +1,9 @@ package com.lazygeniouz.dfc.file.internals import android.content.Context -import android.database.Cursor import android.net.Uri import android.provider.DocumentsContract import com.lazygeniouz.dfc.file.DocumentFileCompat -import com.lazygeniouz.dfc.logger.ErrorLogger import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -115,37 +113,20 @@ internal class SingleDocumentFileCompat( if (!DocumentsContract.isDocumentUri(context, self)) return null ResolverCompat.getCursor(context, self, ResolverCompat.fullProjection) - ?.let { cursor -> return makeFromCursor(context, self, cursor) } + ?.let { cursor -> + return DocumentFileCompat.makeFromCursor( + cursor, + "Exception while building a single document file", + ) { name, size, lastModified, mimeType, flags -> + SingleDocumentFileCompat( + context, self, + name, size, + lastModified, mimeType, flags, + ) + } + } return null } - - @JvmSynthetic - internal fun makeFromCursor( - context: Context, - self: Uri, - cursor: Cursor, - ): SingleDocumentFileCompat? { - 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() - - SingleDocumentFileCompat( - context, self, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags - ) - } - } catch (exception: Exception) { - ErrorLogger.logError("Exception while building a single document file", exception) - null - } - } } } \ No newline at end of file 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 e359113..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 @@ -1,13 +1,11 @@ package com.lazygeniouz.dfc.file.internals import android.content.Context -import android.database.Cursor 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.logger.ErrorLogger import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -131,37 +129,20 @@ internal class TreeDocumentFileCompat( } else uri ResolverCompat.getCursor(context, treeUri, ResolverCompat.fullProjection) - ?.let { cursor -> return makeFromCursor(context, treeUri, cursor) } + ?.let { cursor -> + return DocumentFileCompat.makeFromCursor( + cursor, + "Exception while building a tree document file", + ) { name, size, lastModified, mimeType, flags -> + TreeDocumentFileCompat( + context, treeUri, + name, size, + lastModified, mimeType, flags, + ) + } + } return null } - - @JvmSynthetic - internal fun makeFromCursor( - context: Context, - treeUri: Uri, - cursor: Cursor, - ): TreeDocumentFileCompat? { - 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() - - TreeDocumentFileCompat( - context, treeUri, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags - ) - } - } catch (exception: Exception) { - ErrorLogger.logError("Exception while building a tree document file", exception) - null - } - } } } \ No newline at end of file diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java index 1e9fd7c..e136ee5 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -44,4 +44,20 @@ public void queryListingUsesOnlyListFilesAsPublicName() { ) ); } + + @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/internals/DocumentFileCompatMakeTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt index 64a3bfc..93cee17 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt @@ -6,6 +6,7 @@ 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 @@ -24,7 +25,11 @@ class DocumentFileCompatMakeTest { val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/document/root%2Fnotes.txt") val cursor = throwingCursor(documentCursor("text/plain")) - assertNull(SingleDocumentFileCompat.makeFromCursor(context, uri, cursor)) + assertNull( + DocumentFileCompat.makeFromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) } @Test @@ -33,7 +38,11 @@ class DocumentFileCompatMakeTest { val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/tree/root/document/root") val cursor = throwingCursor(documentCursor(Document.MIME_TYPE_DIR)) - assertNull(TreeDocumentFileCompat.makeFromCursor(context, uri, cursor)) + assertNull( + DocumentFileCompat.makeFromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + TreeDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) } private fun documentCursor(mimeType: String): Cursor { From b2eaf79d5f4d16abd385356c40c9240f119d36a8 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 27 Jul 2026 17:04:57 +0530 Subject: [PATCH 19/36] fix: validate query values --- .../dfc/file/DocumentFileCompat.kt | 1 + .../java/com/lazygeniouz/dfc/file/Query.kt | 30 ++++++++++++++++++- .../file/DocumentFileCompatJavaApiTest.java | 4 ++- .../com/lazygeniouz/dfc/file/QueryTest.kt | 20 +++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) 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 e1a1ae5..4bf7f4a 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -80,6 +80,7 @@ abstract class DocumentFileCompat( * * 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." diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index c18d388..4313142 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -16,6 +16,7 @@ import com.lazygeniouz.dfc.file.Query.Companion.rawSelection * - 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() { @@ -187,10 +188,13 @@ sealed class Query private constructor() { /** * 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 ?)", @@ -502,8 +506,32 @@ sealed class Query private constructor() { private fun Any?.toSqlArg(): String { return when (this) { null -> "null" + is String -> this is Boolean -> if (this) "1" else "0" - else -> toString() + 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." } } } diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java index e136ee5..c5f03d8 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -16,10 +16,12 @@ 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(3, queries.length); + assertEquals(5, queries.length); } @SuppressWarnings("unused") diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 5744e8b..33bfa54 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -118,6 +118,11 @@ class QueryTest { 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()!! @@ -282,6 +287,21 @@ class QueryTest { 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? { From 9555e5dc4121cd645469f923f00144e6814db4ee Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 30 Jul 2026 11:50:53 +0530 Subject: [PATCH 20/36] fix: read document cursor columns by name --- .../dfc/file/DocumentFileCompat.kt | 15 ++++--- .../internals/DocumentFileCompatMakeTest.kt | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) 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 4bf7f4a..99c5795 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -284,11 +284,16 @@ abstract class DocumentFileCompat( 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() + val documentName: String = + it.getString(it.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)) + val documentSize: Long = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_SIZE)) + val documentLastModified: Long = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_LAST_MODIFIED)) + val documentMimeType: String = + it.getString(it.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)) + val documentFlags: Int = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_FLAGS)).toInt() buildFile( documentName, 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 index 93cee17..c9b5f1b 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt @@ -8,7 +8,9 @@ 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.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -19,6 +21,25 @@ import org.robolectric.annotation.Config @Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) class DocumentFileCompatMakeTest { + @Test + fun `makeFromCursor reads columns by name`() { + val context = RuntimeEnvironment.getApplication() + val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/document/root%2Fnotes.txt") + + val file = DocumentFileCompat.makeFromCursor( + shuffledDocumentCursor(), + "test", + ) { name, size, lastModified, mime, flags -> + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + + assertNotNull(file) + assertEquals("notes.txt", file!!.name) + assertEquals(128L, file.length) + assertEquals(7L, file.lastModified) + assertEquals("text/plain", file.getType()) + } + @Test fun `single makeFromCursor returns null when cursor read throws`() { val context = RuntimeEnvironment.getApplication() @@ -68,4 +89,28 @@ class DocumentFileCompatMakeTest { } } } + + private fun shuffledDocumentCursor(): Cursor { + return MatrixCursor( + arrayOf( + Document.COLUMN_FLAGS, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_DOCUMENT_ID, + ) + ).apply { + addRow( + arrayOf( + Document.FLAG_SUPPORTS_WRITE, + 7L, + "text/plain", + 128L, + "notes.txt", + "root/notes.txt", + ) + ) + } + } } From cd0528ae0378c5afc6c96c6ef5475bf8ae48df19 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 30 Jul 2026 12:05:07 +0530 Subject: [PATCH 21/36] test: assert document cursor flags --- .../lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt | 1 + 1 file changed, 1 insertion(+) 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 index c9b5f1b..0b30d9f 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt @@ -38,6 +38,7 @@ class DocumentFileCompatMakeTest { assertEquals(128L, file.length) assertEquals(7L, file.lastModified) assertEquals("text/plain", file.getType()) + assertEquals(Document.FLAG_SUPPORTS_WRITE, file.documentFlags) } @Test From 9d2559d4dc005e8a31ea6d6e840d79dcaf4f52ea Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 30 Jul 2026 12:36:42 +0530 Subject: [PATCH 22/36] fix: preserve query capability flags --- README.md | 3 +- .../java/com/lazygeniouz/dfc/file/Query.kt | 5 +++ .../dfc/resolver/ResolverCompat.kt | 5 ++- .../dfc/resolver/ResolverCompatQueryTest.kt | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index af0b473..75f06b8 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ underlying provider decides what actually gets honored. Some quick tips: -- Use `Query.select(...)` to fetch only the columns you need. +- Use `Query.select(...)` to narrow fetched metadata. `DocumentFileCompat` still adds the + internal columns it needs for child Uris, document types, and capability checks. - Use `Query.limit(...)` for previews, search results, and paged lists. - Prefer exact filters like `filesOnly()`, `mimeType(...)`, and `nameEquals(...)` over broad `nameContains(...)` queries. diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index 4313142..b7d6ae6 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -31,6 +31,11 @@ sealed class Query private constructor() { fun select(vararg columns: String): Query { require(columns.isNotEmpty()) { "select requires at least one column" } columns.forEach { column -> requireAndroidColumnName(column, "column") } + return projection(*columns) + } + + @JvmSynthetic + internal fun projection(vararg columns: String): Query { return QuerySpec( projectionColumnsValue = columns.toList(), description = "select", 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 1b03d1c..95541fd 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -133,7 +133,7 @@ internal object ResolverCompat { projection: Array = fullProjection, ): List { val effectiveProjection = projection.ifEmpty { fullProjection } - return listFiles(context, file, Query.select(*effectiveProjection)) + return listFiles(context, file, Query.projection(*effectiveProjection)) } /** @@ -158,6 +158,9 @@ internal object ResolverCompat { } else { projectionQueries.forEach { addAll(it) } } + + // Required internally for capability checks like canWrite() and isVirtual(). + add(Document.COLUMN_FLAGS) }.toTypedArray() val ignoredQueries = mutableListOf() diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 9d753f8..03afd27 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -81,6 +81,7 @@ class ResolverCompatQueryTest { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, ), provider.lastChildProjection?.toList(), ) @@ -116,6 +117,7 @@ class ResolverCompatQueryTest { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, ), provider.lastChildProjection?.toList(), ) @@ -126,11 +128,13 @@ class ResolverCompatQueryTest { assertTrue(file.isFile()) assertFalse(file.isDirectory()) assertEquals("text/plain", file.getType()) + assertEquals(Document.FLAG_SUPPORTS_WRITE, file.documentFlags) assertSame(root, file.parentFile) assertTrue(directory.isDirectory()) assertFalse(directory.isFile()) assertNull(directory.getType()) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, directory.documentFlags) assertSame(root, directory.parentFile) } @@ -158,6 +162,7 @@ class ResolverCompatQueryTest { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, ), provider.lastChildProjection?.toList(), ) @@ -171,6 +176,32 @@ class ResolverCompatQueryTest { assertEquals(2, children.size) } + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `legacy projection listFiles accepts provider specific columns`() { + 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(arrayOf("vendor.custom-column")) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + "vendor.custom-column", + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + assertEquals(2, children.size) + } + @Test @Config(sdk = [Build.VERSION_CODES.M]) fun `legacy projection listFiles accepts empty projection`() { From a55e45c8c8ca8f88117a3e161c696cd1829f57a0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Thu, 30 Jul 2026 13:00:12 +0530 Subject: [PATCH 23/36] test: cover query bundle composition --- .../dfc/resolver/ResolverCompatQueryTest.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 03afd27..34151ff 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -138,6 +138,54 @@ class ResolverCompatQueryTest { assertSame(root, directory.parentFile) } + @Test + fun `query forwards api 26 sort order bundle argument`() { + 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.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} ASC", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER), + ) + } + + @Test + fun `query joins api 26 filter bundle arguments with and`() { + 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.filesOnly(), + Query.sizeGreaterThan(0L), + ) + + assertEquals( + "(${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?)", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR, "0"), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + @Test @Config(sdk = [Build.VERSION_CODES.M]) fun `query falls back to legacy sort only before api 26`() { From 0c77b327bad64f14af2b7a3769f1e5d5b66d4b89 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 12:04:43 +0530 Subject: [PATCH 24/36] refactor: simplify query listing api --- README.md | 8 +- .../performance/ProjectionPerformance.kt | 5 +- .../dfc/controller/DocumentController.kt | 6 +- .../dfc/file/DocumentFileCompat.kt | 12 +- .../java/com/lazygeniouz/dfc/file/Query.kt | 146 ++++++++++-------- .../file/internals/RawDocumentFileCompat.kt | 10 -- .../internals/SingleDocumentFileCompat.kt | 9 -- .../file/internals/TreeDocumentFileCompat.kt | 7 - .../dfc/resolver/ResolverCompat.kt | 22 --- .../file/DocumentFileCompatJavaApiTest.java | 22 ++- .../com/lazygeniouz/dfc/file/QueryTest.kt | 12 +- .../dfc/resolver/ResolverCompatQueryTest.kt | 69 +-------- 12 files changed, 121 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index 75f06b8..46ec75a 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ do not keep paying for the same queries again and again. - Raw `File` access via `fromFile(...)`. - 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. +- Query-based child listing for projection, filtering, sorting, and paging. - Convenience APIs like `count()`, `copyTo(destination)`, and `copyFrom(source)`. ## Installation @@ -69,7 +68,7 @@ Other entry points: - `DocumentFileCompat.fromFile(context, file)` Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`, -`listFiles(projection)`, and `listFiles(vararg queries)` are available when you need them. +and `listFiles(vararg queries)` are available when you need them. ### Query Child Documents @@ -92,8 +91,7 @@ val recentFiles = directory.listFiles( ``` 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. +are forwarded. On API 26+, filters, `Query.limit(...)`, and `Query.offset(...)` are also forwarded. Providers may still ignore supported query arguments. `DocumentFileCompat` forwards them, but the underlying provider decides what actually gets honored. 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 242b609..ae897e3 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 @@ -5,6 +5,7 @@ import android.net.Uri import android.provider.DocumentsContract.Document import androidx.documentfile.provider.DocumentFile import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.filecompat.example.performance.Performance.measureTimeSeconds object ProjectionPerformance { @@ -57,7 +58,7 @@ object ProjectionPerformance { Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME ) - val files = documentFile?.listFiles(minimalProjection) + val files = documentFile?.listFiles(Query.select(*minimalProjection)) fileCount = files?.size ?: 0 // Verify we can access the names @@ -82,7 +83,7 @@ object ProjectionPerformance { Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE ) - val files = documentFile?.listFiles(partialProjection) + val files = documentFile?.listFiles(Query.select(*partialProjection)) fileCount = files?.size ?: 0 // Calculate total size 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 0c39acb..7d98909 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/controller/DocumentController.kt @@ -32,12 +32,10 @@ internal class DocumentController( /** * This will return a list of [DocumentFileCompat] with all the defined fields. */ - internal fun listFiles( - projection: Array = ResolverCompat.fullProjection, - ): List { + internal fun listFiles(): List { return if (!isDirectory()) throw UnsupportedOperationException("Selected document is not a Directory.") - else ResolverCompat.listFiles(context, fileCompat, projection) + else ResolverCompat.listFiles(context, fileCompat) } /** 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 99c5795..de70824 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -68,17 +68,13 @@ abstract class DocumentFileCompat( */ abstract fun listFiles(): List - /** - * Same as [listFiles] but allows specifying a custom [projection] (columns to query). - * - * Use custom projection to improve performance by fetching only needed data. - */ - abstract fun listFiles(projection: Array): List - /** * List child documents using [Query] clauses. * - * This is only supported for tree-backed directories. + * This is only supported for tree-backed directories. File-backed documents do not evaluate + * provider-level query clauses locally. + * + * @throws UnsupportedOperationException if this document is not a tree-backed directory. */ // Open instead of abstract so existing external subclasses keep source compatibility. open fun listFiles(vararg queries: Query): List { diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index b7d6ae6..1dbba7a 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -1,30 +1,45 @@ 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. + * - API 21-25: only [Query.select], [Query.orderByAsc], and [Query.orderByDesc] are forwarded. + * - API 26+: filter queries, [Query.limit], and [Query.offset] are also forwarded. * * Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses. - * Filter values must be null, String, Number, or Boolean. + * Filter values must be null, String, Number, or Boolean. Float and Double values must be finite. + * + * When multiple queries are passed to the same `listFiles(...)` call: + * + * - [Query.select] clauses are unioned. + * - filter clauses are joined with AND. + * - [Query.orderByAsc] and [Query.orderByDesc] clauses are applied in the order passed. + * - repeated [Query.limit] or [Query.offset] clauses use the last value passed. */ -sealed class Query private constructor() { +class Query private constructor( + private val spec: Spec, +) { + + private class Spec( + val projectionColumns: List? = null, + val sortClause: String? = null, + val limitCount: Int? = null, + val offsetCount: Int? = null, + val selectionPart: Pair>? = null, + val description: String, + ) companion object { /** * Fetch the given columns, plus columns required internally to build results. * + * Use this when combining projection with sort, filter, limit, or offset query clauses. + * * Forwarded on API 21+. */ @JvmStatic @@ -36,9 +51,11 @@ sealed class Query private constructor() { @JvmSynthetic internal fun projection(vararg columns: String): Query { - return QuerySpec( - projectionColumnsValue = columns.toList(), - description = "select", + return Query( + Spec( + projectionColumns = columns.toList(), + description = "select", + ), ) } @@ -65,34 +82,44 @@ sealed class Query private constructor() { /** * Limit the number of returned child documents. * + * If more than one limit is passed to the same `listFiles(...)` call, the last one wins. + * * Forwarded on API 26+. */ @JvmStatic fun limit(count: Int): Query { require(count >= 0) { "limit must be >= 0" } - return QuerySpec( - limitCountValue = count, - description = "limit($count)", + return Query( + Spec( + limitCount = count, + description = "limit($count)", + ), ) } /** * Skip the first [count] child documents. * + * If more than one offset is passed to the same `listFiles(...)` call, the last one wins. + * * Forwarded on API 26+. */ @JvmStatic fun offset(count: Int): Query { require(count >= 0) { "offset must be >= 0" } - return QuerySpec( - offsetCountValue = count, - description = "offset($count)", + return Query( + Spec( + offsetCount = count, + description = "offset($count)", + ), ) } /** * Attribute equals [value]. * + * A null [value] is compiled as `IS NULL`. + * * Forwarded on API 26+. */ @JvmStatic @@ -106,6 +133,8 @@ sealed class Query private constructor() { /** * Attribute does not equal [value]. * + * A null [value] is compiled as `IS NOT NULL`. + * * Forwarded on API 26+. */ @JvmStatic @@ -119,6 +148,8 @@ sealed class Query private constructor() { /** * Attribute equals one of [values]. * + * Null values add an `IS NULL` branch. + * * Forwarded on API 26+. */ @JvmStatic @@ -132,6 +163,8 @@ sealed class Query private constructor() { /** * Attribute does not equal any of [values]. * + * Null values add an `IS NOT NULL` guard. + * * Forwarded on API 26+. */ @JvmStatic @@ -235,6 +268,9 @@ sealed class Query private constructor() { /** * Attribute matches the SQL LIKE [pattern]. * + * The pattern is forwarded as-is. Escape literal `%`, `_`, and `\` yourself, or use + * [nameContains] for display-name contains matching. + * * Forwarded on API 26+. */ @JvmStatic @@ -247,6 +283,9 @@ sealed class Query private constructor() { /** * Attribute does not match the SQL LIKE [pattern]. * + * The pattern is forwarded as-is. Escape literal `%`, `_`, and `\` yourself, or use + * [nameContains] for display-name contains matching. + * * Forwarded on API 26+. */ @JvmStatic @@ -256,24 +295,6 @@ sealed class Query private constructor() { } } - /** - * 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. * @@ -307,6 +328,8 @@ sealed class Query private constructor() { /** * Name contains [value]. * + * SQL LIKE wildcards in [value] are escaped before forwarding. + * * Forwarded on API 26+. */ @JvmStatic @@ -379,48 +402,45 @@ sealed class Query private constructor() { @JvmSynthetic internal fun projectionColumns(query: Query): List? { - return query.asSpec().projectionColumnsValue + return query.spec.projectionColumns } @JvmSynthetic internal fun sortClause(query: Query): String? { - return query.asSpec().sortClauseValue + return query.spec.sortClause } @JvmSynthetic internal fun limitCount(query: Query): Int? { - return query.asSpec().limitCountValue + return query.spec.limitCount } @JvmSynthetic internal fun offsetCount(query: Query): Int? { - return query.asSpec().offsetCountValue + return query.spec.offsetCount } @JvmSynthetic internal fun selectionPart(query: Query): Pair>? { - return query.asSpec().selectionPartValue - } - - @JvmSynthetic - internal fun rawSelectionPart(query: Query): Pair>? { - return query.asSpec().rawSelectionPartValue + return query.spec.selectionPart } @JvmSynthetic internal fun describe(query: Query): String { - return query.asSpec().description - } - - private fun Query.asSpec(): QuerySpec { - return this as QuerySpec + return query.spec.description } 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)", + return Query( + Spec( + sortClause = "$column ${if (descending) "DESC" else "ASC"}", + description = if (descending) { + "orderByDesc($column)" + } else { + "orderByAsc($column)" + }, + ), ) } @@ -430,9 +450,11 @@ sealed class Query private constructor() { build: (String) -> Pair>, ): Query { requireAndroidColumnName(attribute, "attribute") - return QuerySpec( - selectionPartValue = build(attribute), - description = description, + return Query( + Spec( + selectionPart = build(attribute), + description = description, + ), ) } @@ -540,14 +562,4 @@ sealed class Query private constructor() { } } } - - 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 b47ca31..ffcf631 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 @@ -103,16 +103,6 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return file.listFiles()?.map { child -> fromFile(context, child) } ?: emptyList() } - /** - * Returns list of files using File API. - * - * [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() - } - /** * This will return the children count in the directory. * 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 c9155f7..c6ac5e8 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 @@ -50,15 +50,6 @@ internal class SingleDocumentFileCompat( throw UnsupportedOperationException() } - /** - * Single document Uris don't have children, so projections are not applicable. - * - * @throws UnsupportedOperationException - */ - override fun listFiles(projection: Array): List { - return listFiles() - } - /** * No [listFiles], no children, no count. * 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 666a3ba..9b0ffaf 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 @@ -51,13 +51,6 @@ internal class TreeDocumentFileCompat( return treeFileUri?.let { make(context, treeFileUri, false) } } - /** - * This will return a list of [DocumentFileCompat] with the required fields based on the passed [projection]. - */ - override fun listFiles(projection: Array): List { - return fileController.listFiles(projection) - } - override fun listFiles(): List { return fileController.listFiles() } 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 95541fd..44b6e3b 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -124,18 +124,6 @@ internal object ResolverCompat { } } - /** - * Queries the ContentResolver & builds a list of [DocumentFileCompat] with all the required fields. - */ - internal fun listFiles( - context: Context, - file: DocumentFileCompat, - projection: Array = fullProjection, - ): List { - val effectiveProjection = projection.ifEmpty { fullProjection } - return listFiles(context, file, Query.projection(*effectiveProjection)) - } - /** * Queries the ContentResolver using provider-level query arguments and builds * a list of [DocumentFileCompat]. @@ -204,16 +192,6 @@ internal object ResolverCompat { } 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) diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java index c5f03d8..b6c627f 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -1,6 +1,7 @@ package com.lazygeniouz.dfc.file; import android.provider.DocumentsContract.Document; +import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.Arrays; import org.junit.Test; @@ -27,7 +28,6 @@ public void queryFactoriesAreCallableFromJavaSource() { @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() }); } @@ -45,6 +45,12 @@ public void queryListingUsesOnlyListFilesAsPublicName() { && Arrays.equals(method.getParameterTypes(), new Class[] { Query[].class }) ) ); + assertFalse( + Arrays.stream(methods).anyMatch(method -> + method.getName().equals("listFiles") + && Arrays.equals(method.getParameterTypes(), new Class[] { String[].class }) + ) + ); } @Test @@ -62,4 +68,18 @@ public void internalCursorHelperIsSyntheticOnJavaSide() { ) ); } + + @Test + public void queryInternalApiIsSyntheticOnJavaSide() { + Method[] methods = Query.Companion.getClass().getMethods(); + + assertTrue( + Arrays.stream(methods).anyMatch(method -> + method.getName().startsWith("projectionColumns") && method.isSynthetic() + ) + ); + assertTrue( + Arrays.stream(Query.class.getConstructors()).allMatch(Constructor::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 index 33bfa54..9568499 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -263,11 +263,6 @@ class QueryTest { 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") @@ -323,3 +318,10 @@ private fun Query.offsetCount(): Int? { private fun Query.selectionPart(): Pair>? { return Query.selectionPart(this) } + +@Suppress("unused") +private fun listFilesOverloadsAreCallableFromKotlinSource(file: DocumentFileCompat) { + file.listFiles() + file.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + file.listFiles(Query.limit(1)) +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 34151ff..2f05751 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -224,60 +224,6 @@ class ResolverCompatQueryTest { assertEquals(2, children.size) } - @Test - @Config(sdk = [Build.VERSION_CODES.M]) - fun `legacy projection listFiles accepts provider specific columns`() { - 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(arrayOf("vendor.custom-column")) - - assertEquals( - listOf( - Document.COLUMN_DOCUMENT_ID, - Document.COLUMN_MIME_TYPE, - "vendor.custom-column", - Document.COLUMN_FLAGS, - ), - provider.lastChildProjection?.toList(), - ) - 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() @@ -319,7 +265,7 @@ class ResolverCompatQueryTest { } @Test - fun `query forwards raw selection and offset`() { + fun `query forwards offset`() { val context = RuntimeEnvironment.getApplication() val root = TreeDocumentFileCompat( context = context, @@ -329,19 +275,8 @@ class ResolverCompatQueryTest { documentFlags = Document.FLAG_DIR_SUPPORTS_CREATE, ) - root.listFiles( - Query.rawSelection("${Document.COLUMN_DISPLAY_NAME} LIKE ?", "notes%"), - Query.offset(2), - ) + root.listFiles(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)) } From 3406306e6d35aabb2c8967cac5fafebfe00d5c3c Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 12:12:35 +0530 Subject: [PATCH 25/36] feat: add grouped query filters --- README.md | 1 + .../java/com/lazygeniouz/dfc/file/Query.kt | 86 ++++++++++++++++++- .../file/DocumentFileCompatJavaApiTest.java | 5 +- .../com/lazygeniouz/dfc/file/QueryTest.kt | 81 +++++++++++++++++ .../dfc/resolver/ResolverCompatQueryTest.kt | 59 +++++++++++++ 5 files changed, 230 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46ec75a..6c8903a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Some quick tips: - Use `Query.select(...)` to narrow fetched metadata. `DocumentFileCompat` still adds the internal columns it needs for child Uris, document types, and capability checks. +- Use `Query.anyOf(...)`, `Query.allOf(...)`, and `Query.not(...)` for grouped filter logic. - Use `Query.limit(...)` for previews, search results, and paged lists. - Prefer exact filters like `filesOnly()`, `mimeType(...)`, and `nameEquals(...)` over broad `nameContains(...)` queries. diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt index 1dbba7a..36473f6 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -16,7 +16,8 @@ import android.provider.DocumentsContract.Document * When multiple queries are passed to the same `listFiles(...)` call: * * - [Query.select] clauses are unioned. - * - filter clauses are joined with AND. + * - top-level filter clauses are joined with AND. Use [Query.anyOf], [Query.allOf], and + * [Query.not] for grouped filter logic. * - [Query.orderByAsc] and [Query.orderByDesc] clauses are applied in the order passed. * - repeated [Query.limit] or [Query.offset] clauses use the last value passed. */ @@ -295,6 +296,54 @@ class Query private constructor( } } + /** + * Match documents that satisfy every [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun allOf(vararg filters: Query): Query { + return combineFilters("allOf", "AND", filters) + } + + /** + * Match documents that satisfy at least one [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun anyOf(vararg filters: Query): Query { + return combineFilters("anyOf", "OR", filters) + } + + /** + * Match documents that do not satisfy [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun not(filter: Query): Query { + val selectionPart = requireFilterQuery(filter, "not") + return Query( + Spec( + selectionPart = compiledSelection( + "(NOT ${selectionPart.first})", + selectionPart.second, + ), + description = "not(${describe(filter)})", + ), + ) + } + /** * Exclude directories. * @@ -523,6 +572,41 @@ class Query private constructor( } } + private fun combineFilters( + description: String, + operator: String, + filters: Array, + ): Query { + require(filters.isNotEmpty()) { "$description requires at least one filter" } + + val selectionParts = filters.map { filter -> + requireFilterQuery(filter, description) + } + val filterDescription = filters.joinToString { filter -> describe(filter) } + return Query( + Spec( + selectionPart = compiledSelection( + selectionParts.joinToString( + separator = " $operator ", + prefix = "(", + postfix = ")", + ) { selectionPart -> selectionPart.first }, + selectionParts.flatMap { selectionPart -> selectionPart.second }, + ), + description = "$description($filterDescription)", + ), + ) + } + + private fun requireFilterQuery( + query: Query, + parentDescription: String, + ): Pair> { + return query.spec.selectionPart ?: throw IllegalArgumentException( + "$parentDescription accepts only filter queries." + ) + } + private fun compiledSelection( selection: String, args: List, diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java index b6c627f..c659b53 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -19,10 +19,13 @@ public void queryFactoriesAreCallableFromJavaSource() { 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.anyOf(Query.mimeType("image/png"), Query.mimeType("image/jpeg")), + Query.allOf(Query.filesOnly(), Query.sizeGreaterThan(0L)), + Query.not(Query.directoriesOnly()), Query.limit(100), }; - assertEquals(5, queries.length); + assertEquals(8, queries.length); } @SuppressWarnings("unused") diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 9568499..9a1d52c 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -162,6 +162,62 @@ class QueryTest { assertEquals(listOf("report%"), selectionPart.second) } + @Test + fun `allOf compiles grouped and selection`() { + val selectionPart = Query.allOf( + Query.filesOnly(), + Query.sizeGreaterThan(0L), + ).selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?))", + selectionPart.first, + ) + assertEquals(listOf(Document.MIME_TYPE_DIR, "0"), selectionPart.second) + } + + @Test + fun `anyOf compiles grouped or selection`() { + val selectionPart = Query.anyOf( + Query.mimeType("image/png"), + Query.nameContains("report"), + ).selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\'))", + selectionPart.first, + ) + assertEquals(listOf("image/png", "%report%"), selectionPart.second) + } + + @Test + fun `not compiles grouped negation selection`() { + val selectionPart = Query.not(Query.directoriesOnly()).selectionPart()!! + + assertEquals("(NOT (${Document.COLUMN_MIME_TYPE} = ?))", selectionPart.first) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.second) + } + + @Test + fun `grouped filters can nest`() { + val selectionPart = Query.allOf( + Query.anyOf( + Query.nameEquals("a.txt"), + Query.nameEquals("b.txt"), + ), + Query.not(Query.directoriesOnly()), + ).selectionPart()!! + + assertEquals( + "(((${Document.COLUMN_DISPLAY_NAME} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} = ?)) AND " + + "(NOT (${Document.COLUMN_MIME_TYPE} = ?)))", + selectionPart.first, + ) + assertEquals(listOf("a.txt", "b.txt", Document.MIME_TYPE_DIR), selectionPart.second) + } + @Test fun `filesOnly maps to mime type not equal directory`() { val selectionPart = Query.filesOnly().selectionPart()!! @@ -263,6 +319,31 @@ class QueryTest { Query.select() } + @Test(expected = IllegalArgumentException::class) + fun `allOf rejects empty filters`() { + Query.allOf() + } + + @Test(expected = IllegalArgumentException::class) + fun `anyOf rejects empty filters`() { + Query.anyOf() + } + + @Test(expected = IllegalArgumentException::class) + fun `allOf rejects non filter query`() { + Query.allOf(Query.select(Document.COLUMN_DISPLAY_NAME)) + } + + @Test(expected = IllegalArgumentException::class) + fun `anyOf rejects non filter query`() { + Query.anyOf(Query.orderByAsc(Document.COLUMN_DISPLAY_NAME)) + } + + @Test(expected = IllegalArgumentException::class) + fun `not rejects non filter query`() { + Query.not(Query.limit(1)) + } + @Test(expected = IllegalArgumentException::class) fun `orderByAsc rejects unsafe column name`() { Query.orderByAsc("${Document.COLUMN_DISPLAY_NAME}; DROP TABLE documents") diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 2f05751..248d3c5 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -186,6 +186,65 @@ class ResolverCompatQueryTest { ) } + @Test + fun `query forwards grouped allOf filter 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, + ) + + root.listFiles( + Query.allOf( + Query.filesOnly(), + Query.sizeGreaterThan(0L), + ), + ) + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?))", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR, "0"), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + + @Test + fun `query forwards grouped anyOf and not filter 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, + ) + + root.listFiles( + Query.anyOf( + Query.mimeType("image/png"), + Query.nameContains("report"), + ), + Query.not(Query.directoriesOnly()), + ) + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')) AND " + + "(NOT (${Document.COLUMN_MIME_TYPE} = ?))", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf("image/png", "%report%", Document.MIME_TYPE_DIR), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + @Test @Config(sdk = [Build.VERSION_CODES.M]) fun `query falls back to legacy sort only before api 26`() { From 5d29ee9b85b36bf68ed72a270fe9adca28dc177b Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 15:34:46 +0530 Subject: [PATCH 26/36] test: add SAF query android coverage --- dfc/build.gradle | 6 + dfc/src/androidTest/AndroidManifest.xml | 9 + .../dfc/picker/SafTreePickerActivity.kt | 84 ++++ .../dfc/query/SafQueryAndroidTest.kt | 213 +++++++++++ .../dfc/query/SafQueryLoadAndroidTest.kt | 359 ++++++++++++++++++ .../lazygeniouz/dfc/testing/SafTestHelper.kt | 147 +++++++ .../com/lazygeniouz/dfc/file/QueryTest.kt | 48 +++ .../dfc/resolver/ResolverCompatQueryTest.kt | 73 ++++ 8 files changed, 939 insertions(+) create mode 100644 dfc/src/androidTest/AndroidManifest.xml create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt diff --git a/dfc/build.gradle b/dfc/build.gradle index 3f7bc38..39f810d 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -11,6 +11,7 @@ android { defaultConfig { minSdk = 21 targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -29,4 +30,9 @@ android { dependencies { testImplementation "junit:junit:4.13.2" testImplementation "org.robolectric:robolectric:4.16.1" + + androidTestImplementation "junit:junit:4.13.2" + androidTestImplementation "androidx.test:runner:1.7.0" + androidTestImplementation "androidx.test.ext:junit:1.3.0" + androidTestImplementation "androidx.test.uiautomator:uiautomator:2.4.0" } \ No newline at end of file diff --git a/dfc/src/androidTest/AndroidManifest.xml b/dfc/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..7d7cd4f --- /dev/null +++ b/dfc/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt new file mode 100644 index 0000000..d24d557 --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt @@ -0,0 +1,84 @@ +package com.lazygeniouz.dfc.picker + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.DocumentsContract +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class SafTreePickerActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val initialUri = initialUriExtra() + val pickerIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + if (initialUri != null) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) + } + } + startActivityForResult(pickerIntent, REQUEST_TREE) + } + + private fun initialUriExtra(): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_INITIAL_URI, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_INITIAL_URI) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_TREE) { + try { + val uri = data?.data + if (resultCode == RESULT_OK && uri != null) { + val flags = data.flags and ( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + runCatching { + contentResolver.takePersistableUriPermission(uri, flags) + } + resultUri.set(uri) + } + } finally { + resultLatch.countDown() + finish() + } + return + } + + super.onActivityResult(requestCode, resultCode, data) + } + + companion object { + const val EXTRA_INITIAL_URI = "com.lazygeniouz.dfc.picker.EXTRA_INITIAL_URI" + private const val REQUEST_TREE = 1 + private const val RESULT_TIMEOUT_SECONDS = 15L + + private val resultUri = AtomicReference() + private var resultLatch = CountDownLatch(1) + + fun reset() { + resultUri.set(null) + resultLatch = CountDownLatch(1) + } + + fun awaitResult(): Uri? { + resultLatch.await(RESULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return resultUri.get() + } + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt new file mode 100644 index 0000000..922f24a --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt @@ -0,0 +1,213 @@ +package com.lazygeniouz.dfc.query + +import android.content.ContentResolver +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract.Document +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.testing.SafTestHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) +class SafQueryAndroidTest { + + private val saf = SafTestHelper() + private val context = saf.context + + private lateinit var fixtureName: String + private lateinit var fixturePath: String + private lateinit var fixtureDocumentId: String + private lateinit var fixtureTreeUri: Uri + private lateinit var root: DocumentFileCompat + + @Before + fun setUp() { + assumeTrue("ExternalStorageProvider is not available.", saf.hasExternalStorageProvider()) + + saf.shell("rm -rf /sdcard/Download/${FIXTURE_PREFIX}*") + + fixtureName = "$FIXTURE_PREFIX${System.currentTimeMillis()}" + fixturePath = "/sdcard/Download/$fixtureName" + fixtureDocumentId = "${SafTestHelper.PRIMARY_ROOT_ID}:Download/$fixtureName" + + saf.shell("mkdir -p $fixturePath") + + fixtureTreeUri = saf.grantTree(fixtureDocumentId) + root = requireFixtureRoot() + seedFixture() + } + + @After + fun tearDown() { + if (::fixtureTreeUri.isInitialized) { + saf.releaseTree(fixtureTreeUri) + } + if (::fixturePath.isInitialized) { + saf.shell("rm -rf $fixturePath") + } + } + + @Test + fun listFilesReadsSafFixture() { + val children = root.listFiles() + val names = children.map { file -> file.name }.toSet() + + assertEquals( + "Expected all seeded immediate children, actual=$names", + EXPECTED_IMMEDIATE_CHILDREN.toSet(), + names, + ) + assertFalse(names.contains("nested-report.txt")) + + val report = children.first { file -> file.name == "report-alpha.txt" } + assertTrue(report.isFile()) + assertFalse(report.isDirectory()) + assertEquals("text/plain", report.getType()) + assertTrue(report.length > 0L) + + val photos = children.first { file -> file.name == "photos" } + assertTrue(photos.isDirectory()) + assertFalse(photos.isFile()) + assertNull(photos.getType()) + } + + @Test + fun selectKeepsMetadataUsable() { + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + ) + val report = children.first { file -> file.name == "report-alpha.txt" } + val photos = children.first { file -> file.name == "photos" } + + assertEquals("text/plain", report.getType()) + assertTrue(report.isFile()) + assertTrue(report.length > 0L) + assertTrue(report.canWrite()) + + assertNull(photos.getType()) + assertTrue(photos.isDirectory()) + assertTrue(photos.canWrite()) + } + + @Test + fun queryClausesStayProviderBackedAndImmediate() { + val children = root.listFiles( + Query.filesOnly(), + Query.nameContains("report"), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + val names = children.map { file -> file.name } + val honoredArgs = saf.honoredQueryArgs(fixtureTreeUri) + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SELECTION)) { + assertEquals( + "Expected provider-backed filter, actual=$names", + setOf("report-alpha.txt", "report-beta.txt"), + names.toSet(), + ) + assertTrue(children.all { file -> file.isFile() }) + } else { + assertEquals( + "Expected immediate children when provider ignores query args, actual=$names", + EXPECTED_IMMEDIATE_CHILDREN.toSet(), + names.toSet(), + ) + assertFalse(names.contains("nested-report.txt")) + } + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SORT_ORDER)) { + assertEquals( + "Expected provider-backed sort, actual=$names", + names.sorted(), + names, + ) + } + } + + private fun requireFixtureRoot(): DocumentFileCompat { + val root = DocumentFileCompat.fromTreeUri(context, fixtureTreeUri) + assertNotNull(root) + return root!! + } + + private fun seedFixture() { + val photos = requireCreated( + root.createDirectory("photos"), + "Failed to create photos directory via SAF", + ) + requireCreated( + root.createDirectory("empty"), + "Failed to create empty directory via SAF", + ) + requireCreated( + root.createDirectory("report-folder"), + "Failed to create report-folder directory via SAF", + ) + + writeFile( + root.createFile("text/plain", "report-alpha.txt"), + "alpha-report".toByteArray(), + ) + writeFile( + root.createFile("text/plain", "report-beta.txt"), + "beta-report".toByteArray(), + ) + writeFile( + root.createFile("text/plain", "notes.txt"), + "plain-notes".toByteArray(), + ) + writeFile( + root.createFile("image/png", "cover.png"), + byteArrayOf(-119, 80, 78, 71), + ) + writeFile( + root.createFile("application/octet-stream", "big.bin"), + ByteArray(4096) { 1 }, + ) + writeFile( + photos.createFile("text/plain", "nested-report.txt"), + "nested-report".toByteArray(), + ) + } + + private fun writeFile(file: DocumentFileCompat?, bytes: ByteArray) { + val document = requireCreated(file, "Failed to create file via SAF") + context.contentResolver.openOutputStream(document.uri)?.use { stream -> + stream.write(bytes) + } ?: throw AssertionError("Failed to open output stream for ${document.name}") + } + + private fun requireCreated( + file: DocumentFileCompat?, + message: String, + ): DocumentFileCompat { + return file ?: throw AssertionError(message) + } + + private companion object { + val EXPECTED_IMMEDIATE_CHILDREN = listOf( + "report-alpha.txt", + "report-beta.txt", + "notes.txt", + "cover.png", + "big.bin", + "photos", + "empty", + "report-folder", + ) + const val FIXTURE_PREFIX = "DFCQueryAndroidTest_" + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt new file mode 100644 index 0000000..092b09c --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt @@ -0,0 +1,359 @@ +package com.lazygeniouz.dfc.query + +import android.content.ContentResolver +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.provider.DocumentsContract.Document +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.testing.SafTestHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) +class SafQueryLoadAndroidTest { + + private val arguments = InstrumentationRegistry.getArguments() + private val saf = SafTestHelper() + private val context = saf.context + + private lateinit var treeUri: Uri + private lateinit var root: DocumentFileCompat + + @Before + fun setUp() { + assumeTrue( + "Set instrumentation arg $ARG_ENABLE_LOAD_TEST=true to run the SAF load test.", + arguments.getString(ARG_ENABLE_LOAD_TEST).toBoolean(), + ) + assumeTrue("ExternalStorageProvider is not available.", saf.hasExternalStorageProvider()) + + resetFixtureDirectory() + + treeUri = saf.grantTree(FIXTURE_DOCUMENT_ID) + root = requireRoot() + + seedFixture() + val createdCount = fileCount(FIXTURE_PATH) + assertEquals( + "Expected seeded fixture file count", + TOTAL_FILE_COUNT, + createdCount, + ) + } + + @After + fun tearDown() { + if (::treeUri.isInitialized) { + saf.releaseTree(treeUri) + } + if (arguments.getString(ARG_ENABLE_LOAD_TEST).toBoolean()) { + deleteFixtureDirectory() + } + } + + @Test + fun listAndQueryLargeSafFixture() { + timed("count") { + val count = root.count() + assertEquals("Expected root child count", ROOT_CHILD_COUNT, count) + } + + val children = timed("listFiles(select + sort)") { + root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + } + + val names = children.map { file -> file.name } + val nameSet = names.toSet() + assertEquals("Expected root child count", ROOT_CHILD_COUNT, children.size) + assertTrue("Expected docs directory in root", nameSet.contains("docs")) + assertTrue("Expected media directory in root", nameSet.contains("media")) + assertTrue("Expected report files in root", names.any { name -> name.startsWith("report_") }) + assertTrue("Expected image files in root", names.any { name -> name.startsWith("image_") }) + assertFalse( + "Root query should stay immediate, actual nested name present", + names.any { name -> name.startsWith("nested_") }, + ) + + val filtered = timed("query clauses") { + root.listFiles( + Query.filesOnly(), + Query.nameContains("report"), + Query.limit(100), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + } + val filteredNames = filtered.map { file -> file.name } + val honoredArgs = saf.honoredQueryArgs(treeUri) + assertTrue("Expected provider-backed query to return rows", filteredNames.isNotEmpty()) + assertFalse( + "Query should stay immediate, actual nested name present", + filteredNames.any { name -> name.startsWith("nested_") }, + ) + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SELECTION)) { + assertTrue( + "Expected all filtered rows to be files, actual=$filteredNames", + filtered.all { file -> file.isFile() }, + ) + assertTrue( + "Expected all filtered names to contain report, actual=$filteredNames", + filteredNames.all { name -> name.contains("report") }, + ) + } + if (honoredArgs.contains(ContentResolver.QUERY_ARG_LIMIT)) { + assertTrue("Expected limit(100), actual=${filtered.size}", filtered.size <= 100) + } + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SORT_ORDER)) { + assertEquals( + "Expected provider-backed ascending sort, actual=$filteredNames", + filteredNames.sorted(), + filteredNames, + ) + } + + Log.i(TAG, "SAF load test completed") + } + + private fun seedFixture() { + val workerCount = seedWorkerCount() + Log.i(TAG, "Seeding $TOTAL_FILE_COUNT SAF files into $FIXTURE_PATH with $workerCount workers") + val directories = createDirectoryMap() + val createdCount = AtomicInteger() + val executor = Executors.newFixedThreadPool(workerCount) + + val tasks = List(workerCount) { worker -> + Callable { + var index = worker + while (index < TOTAL_FILE_COUNT) { + createLoadFile(index, directories) + + val created = createdCount.incrementAndGet() + if (created % 500 == 0) { + Log.i(TAG, "Seeded $created/$TOTAL_FILE_COUNT files") + } + + index += workerCount + } + } + } + + try { + timed("seed fixture") { + executor.invokeAll(tasks).forEach { future -> future.get() } + } + } finally { + executor.shutdownNow() + } + } + + private fun createLoadFile( + index: Int, + directories: Map, + ) { + val spec = fileSpec(index) + val parent = directories.getValue(spec.directory) + val file = parent.createFile(spec.mimeType, spec.name) + ?: throw AssertionError("Failed to create ${spec.directory}/${spec.name}") + + context.contentResolver.openOutputStream(file.uri)?.use { stream -> + stream.write(bytesFor(index, spec.size)) + } ?: throw AssertionError("Failed to open output stream for ${spec.name}") + } + + private fun seedWorkerCount(): Int { + val requested = arguments.getString(ARG_SEED_WORKERS) + ?.toIntOrNull() + ?.takeIf { count -> count > 0 } + ?: DEFAULT_SEED_WORKERS + + return requested.coerceAtMost(MAX_SEED_WORKERS) + } + + private fun createDirectoryMap(): Map { + val docs = root.requireDirectory("docs") + val media = root.requireDirectory("media") + val logs = root.requireDirectory("logs") + val data = root.requireDirectory("data") + val misc = root.requireDirectory("misc") + + return mapOf( + ROOT_DIR to root, + "docs" to docs, + "media" to media, + "logs" to logs, + "data" to data, + "misc" to misc, + "docs/archive" to docs.requireDirectory("archive"), + "media/camera" to media.requireDirectory("camera"), + "logs/old" to logs.requireDirectory("old"), + "data/exports" to data.requireDirectory("exports"), + "misc/names" to misc.requireDirectory("names"), + ) + } + + private fun DocumentFileCompat.requireDirectory(name: String): DocumentFileCompat { + return createDirectory(name) ?: throw AssertionError("Failed to create $name directory") + } + + private fun fileSpec(index: Int): LoadFileSpec { + val directory = when { + index < ROOT_FILE_COUNT -> ROOT_DIR + index < ROOT_FILE_COUNT + CATEGORY_FILE_COUNT -> CATEGORY_DIRS[ + (index - ROOT_FILE_COUNT) % CATEGORY_DIRS.size + ] + else -> NESTED_DIRS[ + (index - ROOT_FILE_COUNT - CATEGORY_FILE_COUNT) % NESTED_DIRS.size + ] + } + val type = FILE_TYPES[index % FILE_TYPES.size] + val stem = NAME_STEMS[index % NAME_STEMS.size] + val name = "${stem}_${index.toString().padStart(5, '0')}.${type.extension}" + + return LoadFileSpec(directory, name, type.mimeType, sizeFor(index)) + } + + private fun sizeFor(index: Int): Int { + return when (index % 100) { + in 0..9 -> index % 129 + in 10..54 -> 1024 + (index % 4) * 1024 + in 55..84 -> 8192 + (index % 4) * 8192 + in 85..94 -> 65536 + (index % 4) * 32768 + in 95..98 -> 262_144 + else -> 1_048_576 + } + } + + private fun bytesFor(seed: Int, size: Int): ByteArray { + return ByteArray(size) { offset -> ((seed + offset) and 0xff).toByte() } + } + + private fun resetFixtureDirectory() { + saf.shell("rm -rf $FIXTURE_PATH") + saf.shell("mkdir -p $FIXTURE_PATH") + val created = waitForDirectory(FIXTURE_PATH) + assertTrue("Failed to create $FIXTURE_PATH", created) + } + + private fun deleteFixtureDirectory() { + saf.shell("rm -rf $FIXTURE_PATH") + } + + private fun directoryExists(path: String): Boolean { + return saf.shell("ls -d $path").lineSequence().any { line -> + line.trim() == path + } + } + + private fun waitForDirectory(path: String): Boolean { + repeat(10) { + if (directoryExists(path)) return true + SystemClock.sleep(100) + } + return false + } + + private fun fileCount(path: String): Int { + return saf.shell("find $path -type f") + .lineSequence() + .count { line -> line.trim().startsWith(path) } + } + + private fun requireRoot(): DocumentFileCompat { + val file = DocumentFileCompat.fromTreeUri(context, treeUri) + assertNotNull(file) + return file!! + } + + private inline fun timed(label: String, block: () -> T): T { + val start = SystemClock.elapsedRealtime() + return block().also { + Log.i(TAG, "$label took ${SystemClock.elapsedRealtime() - start}ms") + } + } + + private data class FileType( + val extension: String, + val mimeType: String, + ) + + private data class LoadFileSpec( + val directory: String, + val name: String, + val mimeType: String, + val size: Int, + ) + + private companion object { + const val TAG = "DFCLoadTest" + const val ARG_ENABLE_LOAD_TEST = "dfcSafLoad" + const val ARG_SEED_WORKERS = "dfcSafLoadWorkers" + const val FIXTURE_NAME = "DFCQueryPerfFixture" + const val FIXTURE_PATH = "/sdcard/Download/$FIXTURE_NAME" + const val FIXTURE_DOCUMENT_ID = "${SafTestHelper.PRIMARY_ROOT_ID}:Download/$FIXTURE_NAME" + const val ROOT_DIR = "" + const val TOTAL_FILE_COUNT = 5_000 + const val ROOT_FILE_COUNT = 2_000 + const val CATEGORY_FILE_COUNT = 2_000 + const val ROOT_CHILD_COUNT = ROOT_FILE_COUNT + 5 + const val DEFAULT_SEED_WORKERS = 4 + const val MAX_SEED_WORKERS = 8 + + val CATEGORY_DIRS = listOf("docs", "media", "logs", "data", "misc") + val NESTED_DIRS = listOf( + "docs/archive", + "media/camera", + "logs/old", + "data/exports", + "misc/names", + ) + val NAME_STEMS = listOf( + "report", + "invoice", + "notes", + "image", + "clip", + "audio", + "export", + "payload", + "app-log", + "crash-trace", + "spaced name", + "UPPERCASE_NAME", + ) + val FILE_TYPES = listOf( + FileType("txt", "text/plain"), + FileType("log", "text/plain"), + FileType("md", "text/markdown"), + FileType("pdf", "application/pdf"), + FileType("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + FileType("json", "application/json"), + FileType("csv", "text/csv"), + FileType("zip", "application/zip"), + FileType("png", "image/png"), + FileType("jpg", "image/jpeg"), + FileType("mp3", "audio/mpeg"), + FileType("mp4", "video/mp4"), + FileType("bin", "application/octet-stream"), + ) + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt new file mode 100644 index 0000000..f71aede --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt @@ -0,0 +1,147 @@ +package com.lazygeniouz.dfc.testing + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import com.lazygeniouz.dfc.picker.SafTreePickerActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue + +internal class SafTestHelper { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + val context: Context = instrumentation.context + private val device: UiDevice = UiDevice.getInstance(instrumentation) + + fun hasExternalStorageProvider(): Boolean { + return context.packageManager.resolveContentProvider(EXTERNAL_STORAGE_AUTHORITY, 0) != null + } + + fun grantTree(documentId: String): Uri { + SafTreePickerActivity.reset() + val initialUri = DocumentsContract.buildDocumentUri(EXTERNAL_STORAGE_AUTHORITY, documentId) + context.startActivity( + Intent(context, SafTreePickerActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(SafTreePickerActivity.EXTRA_INITIAL_URI, initialUri) + } + ) + + clickUseThisFolder() + clickAllowIfShown() + + val treeUri = SafTreePickerActivity.awaitResult() + assertNotNull(treeUri) + val grantedTreeUri = treeUri!! + assertEquals(EXTERNAL_STORAGE_AUTHORITY, grantedTreeUri.authority) + assertEquals(documentId, DocumentsContract.getTreeDocumentId(grantedTreeUri)) + return grantedTreeUri + } + + fun releaseTree(treeUri: Uri) { + runCatching { + context.contentResolver.releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + + fun shell(command: String): String { + return device.executeShellCommand(command) + } + + fun honoredQueryArgs(treeUri: Uri): Set { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + val queryArgs = Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + "(${Document.COLUMN_MIME_TYPE} != ?) AND " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + ) + putStringArray( + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, + arrayOf(Document.MIME_TYPE_DIR, "%report%"), + ) + putString( + ContentResolver.QUERY_ARG_SQL_SORT_ORDER, + "${Document.COLUMN_DISPLAY_NAME} ASC", + ) + putInt(ContentResolver.QUERY_ARG_LIMIT, 100) + } + + return runCatching { + context.contentResolver.query( + childrenUri, + arrayOf(Document.COLUMN_DISPLAY_NAME), + queryArgs, + null, + )?.use { cursor -> + cursor.extras.getStringArray(ContentResolver.EXTRA_HONORED_ARGS) + ?.toSet() + .orEmpty() + }.orEmpty() + }.getOrDefault(emptySet()) + } + + private fun clickUseThisFolder() { + waitForDocumentsUi() + val selectButton = device.wait( + Until.findObject(By.res("android", "button1")), + PICKER_TIMEOUT_MS, + ) ?: device.wait(Until.findObject(By.textContains("USE THIS FOLDER")), SHORT_TIMEOUT_MS) + ?: findDocumentsUiObject("action_menu_select", SHORT_TIMEOUT_MS) + ?: throw AssertionError("DocumentsUI select-folder button was not found") + selectButton.click() + } + + private fun waitForDocumentsUi() { + val opened = device.wait( + Until.hasObject(By.pkg(GOOGLE_DOCUMENTS_UI_PACKAGE)), + PICKER_TIMEOUT_MS, + ) || device.wait( + Until.hasObject(By.pkg(AOSP_DOCUMENTS_UI_PACKAGE)), + SHORT_TIMEOUT_MS, + ) + assertTrue("DocumentsUI did not open", opened) + } + + private fun clickAllowIfShown() { + val allowButton = device.wait(Until.findObject(By.textContains("ALLOW")), SHORT_TIMEOUT_MS) + ?: device.wait(Until.findObject(By.textContains("Allow")), SHORT_TIMEOUT_MS) + runCatching { allowButton?.click() } + } + + private fun findDocumentsUiObject(resourceName: String, timeout: Long): UiObject2? { + return device.wait( + Until.findObject(By.res(GOOGLE_DOCUMENTS_UI_PACKAGE, resourceName)), + timeout, + ) ?: device.wait( + Until.findObject(By.res(AOSP_DOCUMENTS_UI_PACKAGE, resourceName)), + SHORT_TIMEOUT_MS, + ) + } + + companion object { + const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" + const val PRIMARY_ROOT_ID = "primary" + + private const val GOOGLE_DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui" + private const val AOSP_DOCUMENTS_UI_PACKAGE = "com.android.documentsui" + private const val PICKER_TIMEOUT_MS = 10_000L + private const val SHORT_TIMEOUT_MS = 2_000L + } +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt index 9a1d52c..d43da1e 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -42,6 +42,22 @@ class QueryTest { assertEquals(listOf("image/png"), selectionPart.second) } + @Test + fun `in with only null compiles to is null`() { + val selectionPart = Query.`in`(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `notIn with only null compiles to is not null`() { + val selectionPart = Query.notIn(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + @Test fun `equal with null becomes isNull selection`() { val selectionPart = Query.equal(Document.COLUMN_MIME_TYPE, null).selectionPart()!! @@ -76,6 +92,14 @@ class QueryTest { assertEquals(listOf("report.pdf"), selectionPart.second) } + @Test + fun `boolean values compile to numeric sql args`() { + val selectionPart = Query.equal(Document.COLUMN_FLAGS, true).selectionPart()!! + + assertEquals("(${Document.COLUMN_FLAGS} = ?)", selectionPart.first) + assertEquals(listOf("1"), selectionPart.second) + } + @Test fun `greaterThan compiles correctly`() { val selectionPart = Query.greaterThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! @@ -242,6 +266,30 @@ class QueryTest { assertEquals(listOf("image/png", "image/jpeg"), selectionPart.second) } + @Test + fun `sizeLessThan maps to size less than selection`() { + val selectionPart = Query.sizeLessThan(1024L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `lastModifiedAfter maps to last modified greater than selection`() { + val selectionPart = Query.lastModifiedAfter(123L).selectionPart()!! + + assertEquals("(${Document.COLUMN_LAST_MODIFIED} > ?)", selectionPart.first) + assertEquals(listOf("123"), selectionPart.second) + } + + @Test + fun `lastModifiedBefore maps to last modified less than selection`() { + val selectionPart = Query.lastModifiedBefore(456L).selectionPart()!! + + assertEquals("(${Document.COLUMN_LAST_MODIFIED} < ?)", selectionPart.first) + assertEquals(listOf("456"), selectionPart.second) + } + @Test fun `select returns projection query`() { val query = Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE) diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt index 248d3c5..8fe8afa 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -160,6 +160,79 @@ class ResolverCompatQueryTest { ) } + @Test + fun `query unions multiple select clauses`() { + 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.select(Document.COLUMN_DISPLAY_NAME), + Query.select(Document.COLUMN_SIZE, Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + } + + @Test + fun `query forwards compound sort order in query order`() { + 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.orderByDesc(Document.COLUMN_LAST_MODIFIED), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + "${Document.COLUMN_LAST_MODIFIED} DESC, ${Document.COLUMN_DISPLAY_NAME} ASC", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER), + ) + } + + @Test + fun `query repeated limit and offset use last values`() { + 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.limit(10), + Query.offset(5), + Query.limit(2), + Query.offset(1), + ) + + assertEquals(2, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_LIMIT)) + assertEquals(1, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_OFFSET)) + } + @Test fun `query joins api 26 filter bundle arguments with and`() { val context = RuntimeEnvironment.getApplication() From 7955fae6c0a7bcdf37f5068b2d4cdfd7450945a5 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 15:37:40 +0530 Subject: [PATCH 27/36] ci: run SAF query connected tests --- .github/workflows/build.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5951b8e..36bb827 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,3 +37,33 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug + + connected-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + cache: "gradle" + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run connected tests + uses: ReactiveCircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_4a + disable-animations: true + script: ./gradlew :dfc:connectedDebugAndroidTest From cb6f10f459997f5ea9e1e79308874616b0769be1 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:05:40 +0530 Subject: [PATCH 28/36] ci: rename android instrumentation check --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36bb827..f96f472 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - connected-tests: + android-instrumentation-tests: runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -58,7 +58,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Run connected tests + - name: Run Android instrumentation tests uses: ReactiveCircus/android-emulator-runner@v2 with: api-level: 35 From e0437a70b8b9eccc41c89a6afffef636a00d7bee Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:08:35 +0530 Subject: [PATCH 29/36] ci: name android instrumentation job --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f96f472..799cf01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,8 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - android-instrumentation-tests: + connected-tests: + name: Android instrumentation tests runs-on: ubuntu-latest timeout-minutes: 20 steps: From 8a22158181c62b2c5c58afc0f6a6e286ab7c57d4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:09:56 +0530 Subject: [PATCH 30/36] ci: name build job --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 799cf01..d849633 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ on: jobs: build: + name: Build runs-on: ubuntu-latest steps: - name: Checkout From 19c25e82ddfa2339006f6a2a9fbe2138aa4f5fe0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:25:12 +0530 Subject: [PATCH 31/36] ci: keep required build check name --- .github/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d849633..f96f472 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,6 @@ on: jobs: build: - name: Build runs-on: ubuntu-latest steps: - name: Checkout @@ -39,8 +38,7 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - connected-tests: - name: Android instrumentation tests + android-instrumentation-tests: runs-on: ubuntu-latest timeout-minutes: 20 steps: From b8020b7f8ff46b85290c230cd1e82808fa60c52a Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:29:07 +0530 Subject: [PATCH 32/36] ci: use readable check names --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f96f472..183474b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,7 @@ on: jobs: build: + name: Build runs-on: ubuntu-latest steps: - name: Checkout @@ -39,6 +40,7 @@ jobs: run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug android-instrumentation-tests: + name: Instrumentation Tests runs-on: ubuntu-latest timeout-minutes: 20 steps: From 93b1729d5810d9202fd7c412b893bee827774e82 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 18:58:55 +0530 Subject: [PATCH 33/36] refactor: rename cursor helper --- .../com/lazygeniouz/dfc/file/DocumentFileCompat.kt | 4 ++-- .../dfc/file/internals/SingleDocumentFileCompat.kt | 4 ++-- .../dfc/file/internals/TreeDocumentFileCompat.kt | 4 ++-- .../dfc/file/DocumentFileCompatJavaApiTest.java | 4 ++-- ...Test.kt => DocumentFileCompatFromCursorTest.kt} | 14 +++++++------- 5 files changed, 15 insertions(+), 15 deletions(-) rename dfc/src/test/java/com/lazygeniouz/dfc/file/internals/{DocumentFileCompatMakeTest.kt => DocumentFileCompatFromCursorTest.kt} (87%) 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 de70824..5e717bc 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -265,7 +265,7 @@ abstract class DocumentFileCompat( } @JvmSynthetic - internal fun makeFromCursor( + internal fun fromCursor( cursor: Cursor, errorMessage: String, buildFile: ( @@ -305,4 +305,4 @@ abstract class DocumentFileCompat( } } } -} \ No newline at end of file +} 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 c6ac5e8..0431444 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 @@ -105,7 +105,7 @@ internal class SingleDocumentFileCompat( ResolverCompat.getCursor(context, self, ResolverCompat.fullProjection) ?.let { cursor -> - return DocumentFileCompat.makeFromCursor( + return DocumentFileCompat.fromCursor( cursor, "Exception while building a single document file", ) { name, size, lastModified, mimeType, flags -> @@ -120,4 +120,4 @@ internal class SingleDocumentFileCompat( return null } } -} \ No newline at end of file +} 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 9b0ffaf..7fea53a 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 @@ -123,7 +123,7 @@ internal class TreeDocumentFileCompat( ResolverCompat.getCursor(context, treeUri, ResolverCompat.fullProjection) ?.let { cursor -> - return DocumentFileCompat.makeFromCursor( + return DocumentFileCompat.fromCursor( cursor, "Exception while building a tree document file", ) { name, size, lastModified, mimeType, flags -> @@ -138,4 +138,4 @@ internal class TreeDocumentFileCompat( return null } } -} \ No newline at end of file +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java index c659b53..5c3ec00 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -62,12 +62,12 @@ public void internalCursorHelperIsSyntheticOnJavaSide() { assertTrue( Arrays.stream(methods).anyMatch(method -> - method.getName().startsWith("makeFromCursor") && method.isSynthetic() + method.getName().startsWith("fromCursor") && method.isSynthetic() ) ); assertFalse( Arrays.stream(methods).anyMatch(method -> - method.getName().equals("makeFromCursor") && !method.isSynthetic() + method.getName().equals("fromCursor") && !method.isSynthetic() ) ); } diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt similarity index 87% rename from dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt rename to dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt index 0b30d9f..55b235a 100644 --- a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatMakeTest.kt +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt @@ -19,14 +19,14 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) -class DocumentFileCompatMakeTest { +class DocumentFileCompatFromCursorTest { @Test - fun `makeFromCursor reads columns by name`() { + fun `fromCursor reads columns by name`() { val context = RuntimeEnvironment.getApplication() val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/document/root%2Fnotes.txt") - val file = DocumentFileCompat.makeFromCursor( + val file = DocumentFileCompat.fromCursor( shuffledDocumentCursor(), "test", ) { name, size, lastModified, mime, flags -> @@ -42,26 +42,26 @@ class DocumentFileCompatMakeTest { } @Test - fun `single makeFromCursor returns null when cursor read throws`() { + fun `single fromCursor 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 -> + DocumentFileCompat.fromCursor(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`() { + fun `tree fromCursor 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 -> + DocumentFileCompat.fromCursor(cursor, "test") { name, size, lastModified, mime, flags -> TreeDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) } ) From 2bc40d873f411f16261aff82c9bcbed48b3c30d6 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 19:09:21 +0530 Subject: [PATCH 34/36] chore: restore eof style --- .../main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt | 2 +- .../lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt | 2 +- .../lazygeniouz/dfc/file/internals/TreeDocumentFileCompat.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 5e717bc..5a539f6 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -305,4 +305,4 @@ abstract class DocumentFileCompat( } } } -} +} \ No newline at end of file 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 0431444..d62a608 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 @@ -120,4 +120,4 @@ internal class SingleDocumentFileCompat( return null } } -} +} \ No newline at end of file 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 7fea53a..169cca4 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 @@ -138,4 +138,4 @@ internal class TreeDocumentFileCompat( return null } } -} +} \ No newline at end of file From 9ee1f707015927c38656943d9f382c3f317582d6 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 19:26:36 +0530 Subject: [PATCH 35/36] refactor: require query listing overrides --- .../java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt | 7 +------ .../dfc/file/internals/RawDocumentFileCompat.kt | 7 +++++++ .../dfc/file/internals/SingleDocumentFileCompat.kt | 7 +++++++ 3 files changed, 15 insertions(+), 6 deletions(-) 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 5a539f6..b776151 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -76,12 +76,7 @@ abstract class DocumentFileCompat( * * @throws UnsupportedOperationException if this document is not a tree-backed directory. */ - // 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." - ) - } + abstract fun listFiles(vararg queries: Query): List /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. 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 ffcf631..432e42f 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 @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.logger.ErrorLogger.logError import java.io.File @@ -103,6 +104,12 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return file.listFiles()?.map { child -> fromFile(context, child) } ?: emptyList() } + override fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException( + "Queries are only supported for DocumentsProvider-backed tree URIs." + ) + } + /** * This will return the children count in the directory. * 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 d62a608..431a033 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 @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.provider.DocumentsContract import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query import com.lazygeniouz.dfc.resolver.ResolverCompat /** @@ -50,6 +51,12 @@ internal class SingleDocumentFileCompat( throw UnsupportedOperationException() } + override fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException( + "Queries are only supported for DocumentsProvider-backed tree URIs." + ) + } + /** * No [listFiles], no children, no count. * From f96e6b6c43f3a8caaf51462d405f211613db15bf Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 20:12:24 +0530 Subject: [PATCH 36/36] chore: bump release tooling --- build.gradle | 6 +++--- dfc/build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.gradle b/build.gradle index 6f6e741..93900dc 100644 --- a/build.gradle +++ b/build.gradle @@ -9,10 +9,10 @@ buildscript { } dependencies { - classpath "com.android.tools.build:gradle:9.0.0" + classpath "com.android.tools.build:gradle:9.3.1" classpath "org.jetbrains.dokka:dokka-gradle-plugin:2.2.0" - classpath "com.vanniktech:gradle-maven-publish-plugin:0.36.0" + classpath "com.vanniktech:gradle-maven-publish-plugin:0.37.0" } } @@ -23,7 +23,7 @@ allprojects { } plugins.withId("com.vanniktech.maven.publish.base") { - version = "1.7" + version = "2.0" group = "com.lazygeniouz" mavenPublishing { diff --git a/dfc/build.gradle b/dfc/build.gradle index 39f810d..d3370a1 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -5,12 +5,12 @@ plugins { } android { - compileSdk = 36 + compileSdk = 37 namespace = "com.lazygeniouz.dfc" defaultConfig { minSdk = 21 - targetSdk = 36 + targetSdk = 37 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7c20557..aa21dc1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Fri Feb 06 19:51:09 IST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME