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