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
14 changes: 12 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,5 +35,5 @@ jobs:
java-version: "17"
cache: "gradle"

- name: Build DFC
run: ./gradlew :dfc:assemble
- name: Build
run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ build
.kotlin
.gradle
local.properties

.claude/
.research/
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ do not keep paying for the same queries again and again.
- Common `DocumentFile`-style methods and getters.
- Faster directory listing and metadata access.
- Custom projections for lighter queries.
- Query-based child listing for filtering, sorting, paging, and projection.
- Convenience APIs like `count()`, `copyTo(destination)`, and `copyFrom(source)`.

## Installation
Expand Down Expand Up @@ -67,8 +68,45 @@ Other entry points:
- `DocumentFileCompat.fromSingleUri(context, uri)`
- `DocumentFileCompat.fromFile(context, file)`

Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`, and
`listFiles(projection)` are available when you need them.
Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`,
`listFiles(projection)`, and `listFiles(vararg queries)` are available when you need them.

### Query Child Documents

For tree-backed SAF directories, `Query` lets you pass projection, sort, filter, limit, and offset
hints without dropping down to raw `ContentResolver` code.

```kotlin
import android.provider.DocumentsContract.Document
import com.lazygeniouz.dfc.file.Query

val recentFiles = directory.listFiles(
Query.filesOnly(),
Query.orderByDesc(Document.COLUMN_LAST_MODIFIED),
Query.limit(100),
Query.select(
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_SIZE,
),
)
```

On API 21-25, only `Query.select(...)`, `Query.orderByAsc(...)`, and `Query.orderByDesc(...)`
are forwarded. On API 26+, filters, `Query.limit(...)`, `Query.offset(...)`, and
`Query.rawSelection(...)` are also forwarded.

Providers may still ignore supported query arguments. `DocumentFileCompat` forwards them, but the
underlying provider decides what actually gets honored.

Some quick tips:

- Use `Query.select(...)` to fetch only the columns you need.
- Use `Query.limit(...)` for previews, search results, and paged lists.
- Prefer exact filters like `filesOnly()`, `mimeType(...)`, and `nameEquals(...)` over broad
`nameContains(...)` queries.
- Avoid repeated `listFiles(...)` calls for the same directory; reuse the returned list when you
can.
- Treat queries as fast-path provider hints, not guaranteed filtering across every provider.

## Performance

Expand Down
10 changes: 5 additions & 5 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -36,10 +36,10 @@ android {

dependencies {
implementation project(":dfc")
// implementation "com.lazygeniouz:dfc:1.3"
// implementation "com.lazygeniouz:dfc:<version>"

implementation "androidx.appcompat:appcompat:1.7.1"
implementation "androidx.activity:activity-ktx:1.12.3"
implementation "androidx.activity:activity-ktx:1.13.0"
implementation "androidx.documentfile:documentfile:1.1.0"
implementation "com.google.android.material:material:1.13.0"
implementation "com.google.android.material:material:1.14.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ object ProjectionPerformance {
// Test 1: Full projection (default)
results += testFullProjection(context, uri) + "\n\n"

// Test 2: Minimal projection (ID + Name only)
// Test 2: Minimal requested projection.
results += testMinimalProjection(context, uri) + "\n\n"

// Test 3: ID + Name + Size
// Test 3: Partial requested projection.
results += testPartialProjection(context, uri) + "\n\n"

results += "=".repeat(48).plus("\n\n")
Expand Down Expand Up @@ -52,7 +52,7 @@ object ProjectionPerformance {
var fileCount = 0
measureTimeSeconds {
val documentFile = DocumentFileCompat.fromTreeUri(context, uri)
// Only fetch ID and Name
// MIME type is still added internally so child files keep the right behavior.
val minimalProjection = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME
Expand All @@ -65,7 +65,7 @@ object ProjectionPerformance {
val name = file.name // Should work
}
}.also { time ->
return "Minimal Projection (ID + Name):\n" +
return "Minimal Projection (ID + Name; MIME added internally):\n" +
"Files: $fileCount\n" +
"Time: ${time}s"
}
Expand All @@ -76,7 +76,7 @@ object ProjectionPerformance {
var totalSize = 0L
measureTimeSeconds {
val documentFile = DocumentFileCompat.fromTreeUri(context, uri)
// Fetch ID, Name, and Size
// MIME type is still added internally so child files keep the right behavior.
val partialProjection = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Expand All @@ -91,7 +91,7 @@ object ProjectionPerformance {
}
}.also { time ->
val sizeMb = Performance.getSizeInMb(totalSize)
return "Partial Projection (ID + Name + Size):\n" +
return "Partial Projection (ID + Name + Size; MIME added internally):\n" +
"Files: $fileCount\n" +
"Total Size: $sizeMb\n" +
"Time: ${time}s"
Expand Down
22 changes: 16 additions & 6 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,6 +48,7 @@
android:id="@+id/buttonProjections"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginVertical="12dp"
android:paddingVertical="12dp"
android:text="@string/test_custom_projections" />
Expand Down
9 changes: 8 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ allprojects {
}

plugins.withId("com.vanniktech.maven.publish.base") {
version = "1.6"
version = "1.7"
group = "com.lazygeniouz"

mavenPublishing {
Expand All @@ -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"))
}
}
}

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

dependencies {
testImplementation "junit:junit:4.13.2"
testImplementation "org.robolectric:robolectric:4.16.1"
}
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.listFiles(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
50 changes: 50 additions & 0 deletions dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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
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

/**
Expand Down Expand Up @@ -73,6 +75,18 @@ 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.
*/
// Open instead of abstract so existing external subclasses keep source compatibility.
open fun listFiles(vararg queries: Query): List<DocumentFileCompat> {
throw UnsupportedOperationException(
"Queries are only supported for DocumentsProvider-backed tree URIs."
)
}

/**
* This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects.
*
Expand Down Expand Up @@ -253,5 +267,41 @@ abstract class DocumentFileCompat(
val paths = uri.pathSegments
return paths.size >= 2 && "tree" == paths[0]
}

@JvmSynthetic
internal fun <T : DocumentFileCompat> 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
}
}
}
}
Loading
Loading