Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7b48cd0
improve: add query api.
ItzNotABug Mar 29, 2026
eb8fb30
improve: query tests.
ItzNotABug Mar 29, 2026
f027246
update: sample for query checks.
ItzNotABug Mar 29, 2026
20503eb
update: readme.
ItzNotABug Mar 29, 2026
819c77c
merge: sync query support with master.
ItzNotABug Jul 26, 2026
771f6ab
fix: harden query api.
ItzNotABug Jul 26, 2026
d07b534
fix: clean up query release surface.
ItzNotABug Jul 27, 2026
44a0f65
chore: update gitignore
ItzNotABug Jul 27, 2026
96b3b0e
docs: clarify raw file query behavior
ItzNotABug Jul 27, 2026
9d8b13f
refactor: simplify query support cleanup
ItzNotABug Jul 27, 2026
1986f9a
chore: bump sample app dependencies
ItzNotABug Jul 27, 2026
bb66633
fix: keep query listing under listFiles
ItzNotABug Jul 27, 2026
b8adf70
docs: add query usage tips
ItzNotABug Jul 27, 2026
032076e
chore: restore eof style
ItzNotABug Jul 27, 2026
d9262ee
fix: require mime type for query children
ItzNotABug Jul 27, 2026
ebcaabd
fix: validate query projection columns
ItzNotABug Jul 27, 2026
c7eeb5f
fix: harden query cursor handling
ItzNotABug Jul 27, 2026
b1738ef
fix: harden document cursor reads
ItzNotABug Jul 27, 2026
4476d35
refactor: share document cursor parsing
ItzNotABug Jul 27, 2026
b2eaf79
fix: validate query values
ItzNotABug Jul 27, 2026
9555e5d
fix: read document cursor columns by name
ItzNotABug Jul 30, 2026
cd0528a
test: assert document cursor flags
ItzNotABug Jul 30, 2026
9d2559d
fix: preserve query capability flags
ItzNotABug Jul 30, 2026
a55e45c
test: cover query bundle composition
ItzNotABug Jul 30, 2026
0c77b32
refactor: simplify query listing api
ItzNotABug Aug 2, 2026
3406306
feat: add grouped query filters
ItzNotABug Aug 2, 2026
5d29ee9
test: add SAF query android coverage
ItzNotABug Aug 2, 2026
7955fae
ci: run SAF query connected tests
ItzNotABug Aug 2, 2026
cb6f10f
ci: rename android instrumentation check
ItzNotABug Aug 2, 2026
e0437a7
ci: name android instrumentation job
ItzNotABug Aug 2, 2026
8a22158
ci: name build job
ItzNotABug Aug 2, 2026
19c25e8
ci: keep required build check name
ItzNotABug Aug 2, 2026
b8020b7
ci: use readable check names
ItzNotABug Aug 2, 2026
93b1729
refactor: rename cursor helper
ItzNotABug Aug 2, 2026
2bc40d8
chore: restore eof style
ItzNotABug Aug 2, 2026
9ee1f70
refactor: require query listing overrides
ItzNotABug Aug 2, 2026
f96e6b6
chore: bump release tooling
ItzNotABug Aug 2, 2026
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
46 changes: 44 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ 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:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -25,5 +36,36 @@ jobs:
java-version: "17"
cache: "gradle"

- name: Build DFC
run: ./gradlew :dfc:assemble
- name: Build
run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug

android-instrumentation-tests:
name: Instrumentation Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
cache: "gradle"

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run Android instrumentation tests
uses: ReactiveCircus/android-emulator-runner@v2
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel_4a
disable-animations: true
script: ./gradlew :dfc:connectedDebugAndroidTest
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/
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ do not keep paying for the same queries again and again.
- Raw `File` access via `fromFile(...)`.
- Common `DocumentFile`-style methods and getters.
- Faster directory listing and metadata access.
- Custom projections for lighter queries.
- Query-based child listing for projection, filtering, sorting, and paging.
- Convenience APIs like `count()`, `copyTo(destination)`, and `copyFrom(source)`.

## Installation
Expand Down Expand Up @@ -67,8 +67,46 @@ 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)`,
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(...)`, and `Query.offset(...)` are also forwarded.

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

Some quick tips:

- Use `Query.select(...)` to narrow fetched metadata. `DocumentFileCompat` still adds the
internal columns it needs for child Uris, document types, and capability checks.
- Use `Query.anyOf(...)`, `Query.allOf(...)`, and `Query.not(...)` for grouped filter logic.
- Use `Query.limit(...)` for previews, search results, and paged lists.
- Prefer exact filters like `filesOnly()`, `mimeType(...)`, and `nameEquals(...)` over broad
`nameContains(...)` queries.
- 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 @@ -5,6 +5,7 @@ import android.net.Uri
import android.provider.DocumentsContract.Document
import androidx.documentfile.provider.DocumentFile
import com.lazygeniouz.dfc.file.DocumentFileCompat
import com.lazygeniouz.dfc.file.Query
import com.lazygeniouz.filecompat.example.performance.Performance.measureTimeSeconds

object ProjectionPerformance {
Expand All @@ -18,10 +19,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,20 +53,20 @@ 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
)
val files = documentFile?.listFiles(minimalProjection)
val files = documentFile?.listFiles(Query.select(*minimalProjection))
fileCount = files?.size ?: 0

// Verify we can access the names
files?.forEach { file ->
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,13 +77,13 @@ 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,
Document.COLUMN_SIZE
)
val files = documentFile?.listFiles(partialProjection)
val files = documentFile?.listFiles(Query.select(*partialProjection))
fileCount = files?.size ?: 0

// Calculate total size
Expand All @@ -91,7 +92,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
13 changes: 10 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ buildscript {
}

dependencies {
classpath "com.android.tools.build:gradle:9.0.0"
classpath "com.android.tools.build:gradle:9.3.1"

classpath "org.jetbrains.dokka:dokka-gradle-plugin:2.2.0"
classpath "com.vanniktech:gradle-maven-publish-plugin:0.36.0"
classpath "com.vanniktech:gradle-maven-publish-plugin:0.37.0"
}
}

Expand All @@ -23,7 +23,7 @@ allprojects {
}

plugins.withId("com.vanniktech.maven.publish.base") {
version = "1.6"
version = "2.0"
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
15 changes: 13 additions & 2 deletions dfc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ plugins {
}

android {
compileSdk = 36
compileSdk = 37
namespace = "com.lazygeniouz.dfc"

defaultConfig {
minSdk = 21
targetSdk = 36
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
Expand All @@ -24,4 +25,14 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

dependencies {
testImplementation "junit:junit:4.13.2"
testImplementation "org.robolectric:robolectric:4.16.1"

androidTestImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test:runner:1.7.0"
androidTestImplementation "androidx.test.ext:junit:1.3.0"
androidTestImplementation "androidx.test.uiautomator:uiautomator:2.4.0"
}
9 changes: 9 additions & 0 deletions dfc/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<activity
android:name="com.lazygeniouz.dfc.picker.SafTreePickerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
</application>
</manifest>
Loading
Loading