From 7b48cd01c9be7a4ea2c210f77a917371dd3e999c Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 29 Mar 2026 14:40:42 +0530 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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? {