Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 81 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,48 @@
# 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:

[<img src="/screenshots/filecompat_directory_perf.jpeg" height="500"/>](/screenshots/filecompat_directory_perf.jpeg)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[<img src="/screenshots/filecompat_file_perf.jpeg" height="500"/>](/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) <strike>and therefore this API does
not offer too much out of the box</strike>.\
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

Expand All @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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<com.lazygeniouz.dfc.file.DocumentFileCompat>()
val providerTime = measureTimeSeconds {
providerResult = documentFile.listFiles(Query.filesOnly())
}

var clientSideResult = emptyList<com.lazygeniouz.dfc.file.DocumentFileCompat>()
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}"
}
}
24 changes: 17 additions & 7 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/fileNames"
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp" />
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">

<TextView
android:id="@+id/fileNames"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="12dp" />
</ScrollView>

<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:indeterminate="true"
android:visibility="gone" />

<Button
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" />
Expand All @@ -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" />
Expand All @@ -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" />

</LinearLayout>
</LinearLayout>
4 changes: 4 additions & 0 deletions dfc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

dependencies {
testImplementation "junit:junit:4.13.2"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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<DocumentFileCompat> {
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.
*
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ abstract class DocumentFileCompat(
*/
abstract fun listFiles(projection: Array<String>): List<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 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<DocumentFileCompat>

/**
* This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects.
*
Expand Down Expand Up @@ -255,4 +269,4 @@ abstract class DocumentFileCompat(
return paths.size >= 2 && "tree" == paths[0]
}
}
}
}
Loading
Loading