diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0cd48d..183474b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 @@ -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 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/ diff --git a/README.md b/README.md index b45e965..6c8903a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 4409bfc..35b95ed 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" @@ -36,10 +36,10 @@ 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.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" } \ No newline at end of file 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..ae897e3 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 @@ -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 { @@ -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") @@ -52,12 +53,12 @@ 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 @@ -65,7 +66,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" } @@ -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 @@ -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" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 7a537b1..a57785f 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,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" /> diff --git a/build.gradle b/build.gradle index e078c4c..93900dc 100644 --- a/build.gradle +++ b/build.gradle @@ -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" } } @@ -23,7 +23,7 @@ allprojects { } plugins.withId("com.vanniktech.maven.publish.base") { - version = "1.6" + version = "2.0" group = "com.lazygeniouz" mavenPublishing { @@ -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/build.gradle b/dfc/build.gradle index 6aca964..d3370a1 100644 --- a/dfc/build.gradle +++ b/dfc/build.gradle @@ -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 { @@ -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" } \ No newline at end of file diff --git a/dfc/src/androidTest/AndroidManifest.xml b/dfc/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..7d7cd4f --- /dev/null +++ b/dfc/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt new file mode 100644 index 0000000..d24d557 --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt @@ -0,0 +1,84 @@ +package com.lazygeniouz.dfc.picker + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.DocumentsContract +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class SafTreePickerActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val initialUri = initialUriExtra() + val pickerIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + if (initialUri != null) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) + } + } + startActivityForResult(pickerIntent, REQUEST_TREE) + } + + private fun initialUriExtra(): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_INITIAL_URI, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_INITIAL_URI) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_TREE) { + try { + val uri = data?.data + if (resultCode == RESULT_OK && uri != null) { + val flags = data.flags and ( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + runCatching { + contentResolver.takePersistableUriPermission(uri, flags) + } + resultUri.set(uri) + } + } finally { + resultLatch.countDown() + finish() + } + return + } + + super.onActivityResult(requestCode, resultCode, data) + } + + companion object { + const val EXTRA_INITIAL_URI = "com.lazygeniouz.dfc.picker.EXTRA_INITIAL_URI" + private const val REQUEST_TREE = 1 + private const val RESULT_TIMEOUT_SECONDS = 15L + + private val resultUri = AtomicReference() + private var resultLatch = CountDownLatch(1) + + fun reset() { + resultUri.set(null) + resultLatch = CountDownLatch(1) + } + + fun awaitResult(): Uri? { + resultLatch.await(RESULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return resultUri.get() + } + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt new file mode 100644 index 0000000..922f24a --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt @@ -0,0 +1,213 @@ +package com.lazygeniouz.dfc.query + +import android.content.ContentResolver +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract.Document +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.testing.SafTestHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) +class SafQueryAndroidTest { + + private val saf = SafTestHelper() + private val context = saf.context + + private lateinit var fixtureName: String + private lateinit var fixturePath: String + private lateinit var fixtureDocumentId: String + private lateinit var fixtureTreeUri: Uri + private lateinit var root: DocumentFileCompat + + @Before + fun setUp() { + assumeTrue("ExternalStorageProvider is not available.", saf.hasExternalStorageProvider()) + + saf.shell("rm -rf /sdcard/Download/${FIXTURE_PREFIX}*") + + fixtureName = "$FIXTURE_PREFIX${System.currentTimeMillis()}" + fixturePath = "/sdcard/Download/$fixtureName" + fixtureDocumentId = "${SafTestHelper.PRIMARY_ROOT_ID}:Download/$fixtureName" + + saf.shell("mkdir -p $fixturePath") + + fixtureTreeUri = saf.grantTree(fixtureDocumentId) + root = requireFixtureRoot() + seedFixture() + } + + @After + fun tearDown() { + if (::fixtureTreeUri.isInitialized) { + saf.releaseTree(fixtureTreeUri) + } + if (::fixturePath.isInitialized) { + saf.shell("rm -rf $fixturePath") + } + } + + @Test + fun listFilesReadsSafFixture() { + val children = root.listFiles() + val names = children.map { file -> file.name }.toSet() + + assertEquals( + "Expected all seeded immediate children, actual=$names", + EXPECTED_IMMEDIATE_CHILDREN.toSet(), + names, + ) + assertFalse(names.contains("nested-report.txt")) + + val report = children.first { file -> file.name == "report-alpha.txt" } + assertTrue(report.isFile()) + assertFalse(report.isDirectory()) + assertEquals("text/plain", report.getType()) + assertTrue(report.length > 0L) + + val photos = children.first { file -> file.name == "photos" } + assertTrue(photos.isDirectory()) + assertFalse(photos.isFile()) + assertNull(photos.getType()) + } + + @Test + fun selectKeepsMetadataUsable() { + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + ) + val report = children.first { file -> file.name == "report-alpha.txt" } + val photos = children.first { file -> file.name == "photos" } + + assertEquals("text/plain", report.getType()) + assertTrue(report.isFile()) + assertTrue(report.length > 0L) + assertTrue(report.canWrite()) + + assertNull(photos.getType()) + assertTrue(photos.isDirectory()) + assertTrue(photos.canWrite()) + } + + @Test + fun queryClausesStayProviderBackedAndImmediate() { + val children = root.listFiles( + Query.filesOnly(), + Query.nameContains("report"), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + val names = children.map { file -> file.name } + val honoredArgs = saf.honoredQueryArgs(fixtureTreeUri) + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SELECTION)) { + assertEquals( + "Expected provider-backed filter, actual=$names", + setOf("report-alpha.txt", "report-beta.txt"), + names.toSet(), + ) + assertTrue(children.all { file -> file.isFile() }) + } else { + assertEquals( + "Expected immediate children when provider ignores query args, actual=$names", + EXPECTED_IMMEDIATE_CHILDREN.toSet(), + names.toSet(), + ) + assertFalse(names.contains("nested-report.txt")) + } + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SORT_ORDER)) { + assertEquals( + "Expected provider-backed sort, actual=$names", + names.sorted(), + names, + ) + } + } + + private fun requireFixtureRoot(): DocumentFileCompat { + val root = DocumentFileCompat.fromTreeUri(context, fixtureTreeUri) + assertNotNull(root) + return root!! + } + + private fun seedFixture() { + val photos = requireCreated( + root.createDirectory("photos"), + "Failed to create photos directory via SAF", + ) + requireCreated( + root.createDirectory("empty"), + "Failed to create empty directory via SAF", + ) + requireCreated( + root.createDirectory("report-folder"), + "Failed to create report-folder directory via SAF", + ) + + writeFile( + root.createFile("text/plain", "report-alpha.txt"), + "alpha-report".toByteArray(), + ) + writeFile( + root.createFile("text/plain", "report-beta.txt"), + "beta-report".toByteArray(), + ) + writeFile( + root.createFile("text/plain", "notes.txt"), + "plain-notes".toByteArray(), + ) + writeFile( + root.createFile("image/png", "cover.png"), + byteArrayOf(-119, 80, 78, 71), + ) + writeFile( + root.createFile("application/octet-stream", "big.bin"), + ByteArray(4096) { 1 }, + ) + writeFile( + photos.createFile("text/plain", "nested-report.txt"), + "nested-report".toByteArray(), + ) + } + + private fun writeFile(file: DocumentFileCompat?, bytes: ByteArray) { + val document = requireCreated(file, "Failed to create file via SAF") + context.contentResolver.openOutputStream(document.uri)?.use { stream -> + stream.write(bytes) + } ?: throw AssertionError("Failed to open output stream for ${document.name}") + } + + private fun requireCreated( + file: DocumentFileCompat?, + message: String, + ): DocumentFileCompat { + return file ?: throw AssertionError(message) + } + + private companion object { + val EXPECTED_IMMEDIATE_CHILDREN = listOf( + "report-alpha.txt", + "report-beta.txt", + "notes.txt", + "cover.png", + "big.bin", + "photos", + "empty", + "report-folder", + ) + const val FIXTURE_PREFIX = "DFCQueryAndroidTest_" + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt new file mode 100644 index 0000000..092b09c --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt @@ -0,0 +1,359 @@ +package com.lazygeniouz.dfc.query + +import android.content.ContentResolver +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.provider.DocumentsContract.Document +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.testing.SafTestHelper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) +class SafQueryLoadAndroidTest { + + private val arguments = InstrumentationRegistry.getArguments() + private val saf = SafTestHelper() + private val context = saf.context + + private lateinit var treeUri: Uri + private lateinit var root: DocumentFileCompat + + @Before + fun setUp() { + assumeTrue( + "Set instrumentation arg $ARG_ENABLE_LOAD_TEST=true to run the SAF load test.", + arguments.getString(ARG_ENABLE_LOAD_TEST).toBoolean(), + ) + assumeTrue("ExternalStorageProvider is not available.", saf.hasExternalStorageProvider()) + + resetFixtureDirectory() + + treeUri = saf.grantTree(FIXTURE_DOCUMENT_ID) + root = requireRoot() + + seedFixture() + val createdCount = fileCount(FIXTURE_PATH) + assertEquals( + "Expected seeded fixture file count", + TOTAL_FILE_COUNT, + createdCount, + ) + } + + @After + fun tearDown() { + if (::treeUri.isInitialized) { + saf.releaseTree(treeUri) + } + if (arguments.getString(ARG_ENABLE_LOAD_TEST).toBoolean()) { + deleteFixtureDirectory() + } + } + + @Test + fun listAndQueryLargeSafFixture() { + timed("count") { + val count = root.count() + assertEquals("Expected root child count", ROOT_CHILD_COUNT, count) + } + + val children = timed("listFiles(select + sort)") { + root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME, Document.COLUMN_SIZE), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + } + + val names = children.map { file -> file.name } + val nameSet = names.toSet() + assertEquals("Expected root child count", ROOT_CHILD_COUNT, children.size) + assertTrue("Expected docs directory in root", nameSet.contains("docs")) + assertTrue("Expected media directory in root", nameSet.contains("media")) + assertTrue("Expected report files in root", names.any { name -> name.startsWith("report_") }) + assertTrue("Expected image files in root", names.any { name -> name.startsWith("image_") }) + assertFalse( + "Root query should stay immediate, actual nested name present", + names.any { name -> name.startsWith("nested_") }, + ) + + val filtered = timed("query clauses") { + root.listFiles( + Query.filesOnly(), + Query.nameContains("report"), + Query.limit(100), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + } + val filteredNames = filtered.map { file -> file.name } + val honoredArgs = saf.honoredQueryArgs(treeUri) + assertTrue("Expected provider-backed query to return rows", filteredNames.isNotEmpty()) + assertFalse( + "Query should stay immediate, actual nested name present", + filteredNames.any { name -> name.startsWith("nested_") }, + ) + + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SELECTION)) { + assertTrue( + "Expected all filtered rows to be files, actual=$filteredNames", + filtered.all { file -> file.isFile() }, + ) + assertTrue( + "Expected all filtered names to contain report, actual=$filteredNames", + filteredNames.all { name -> name.contains("report") }, + ) + } + if (honoredArgs.contains(ContentResolver.QUERY_ARG_LIMIT)) { + assertTrue("Expected limit(100), actual=${filtered.size}", filtered.size <= 100) + } + if (honoredArgs.contains(ContentResolver.QUERY_ARG_SQL_SORT_ORDER)) { + assertEquals( + "Expected provider-backed ascending sort, actual=$filteredNames", + filteredNames.sorted(), + filteredNames, + ) + } + + Log.i(TAG, "SAF load test completed") + } + + private fun seedFixture() { + val workerCount = seedWorkerCount() + Log.i(TAG, "Seeding $TOTAL_FILE_COUNT SAF files into $FIXTURE_PATH with $workerCount workers") + val directories = createDirectoryMap() + val createdCount = AtomicInteger() + val executor = Executors.newFixedThreadPool(workerCount) + + val tasks = List(workerCount) { worker -> + Callable { + var index = worker + while (index < TOTAL_FILE_COUNT) { + createLoadFile(index, directories) + + val created = createdCount.incrementAndGet() + if (created % 500 == 0) { + Log.i(TAG, "Seeded $created/$TOTAL_FILE_COUNT files") + } + + index += workerCount + } + } + } + + try { + timed("seed fixture") { + executor.invokeAll(tasks).forEach { future -> future.get() } + } + } finally { + executor.shutdownNow() + } + } + + private fun createLoadFile( + index: Int, + directories: Map, + ) { + val spec = fileSpec(index) + val parent = directories.getValue(spec.directory) + val file = parent.createFile(spec.mimeType, spec.name) + ?: throw AssertionError("Failed to create ${spec.directory}/${spec.name}") + + context.contentResolver.openOutputStream(file.uri)?.use { stream -> + stream.write(bytesFor(index, spec.size)) + } ?: throw AssertionError("Failed to open output stream for ${spec.name}") + } + + private fun seedWorkerCount(): Int { + val requested = arguments.getString(ARG_SEED_WORKERS) + ?.toIntOrNull() + ?.takeIf { count -> count > 0 } + ?: DEFAULT_SEED_WORKERS + + return requested.coerceAtMost(MAX_SEED_WORKERS) + } + + private fun createDirectoryMap(): Map { + val docs = root.requireDirectory("docs") + val media = root.requireDirectory("media") + val logs = root.requireDirectory("logs") + val data = root.requireDirectory("data") + val misc = root.requireDirectory("misc") + + return mapOf( + ROOT_DIR to root, + "docs" to docs, + "media" to media, + "logs" to logs, + "data" to data, + "misc" to misc, + "docs/archive" to docs.requireDirectory("archive"), + "media/camera" to media.requireDirectory("camera"), + "logs/old" to logs.requireDirectory("old"), + "data/exports" to data.requireDirectory("exports"), + "misc/names" to misc.requireDirectory("names"), + ) + } + + private fun DocumentFileCompat.requireDirectory(name: String): DocumentFileCompat { + return createDirectory(name) ?: throw AssertionError("Failed to create $name directory") + } + + private fun fileSpec(index: Int): LoadFileSpec { + val directory = when { + index < ROOT_FILE_COUNT -> ROOT_DIR + index < ROOT_FILE_COUNT + CATEGORY_FILE_COUNT -> CATEGORY_DIRS[ + (index - ROOT_FILE_COUNT) % CATEGORY_DIRS.size + ] + else -> NESTED_DIRS[ + (index - ROOT_FILE_COUNT - CATEGORY_FILE_COUNT) % NESTED_DIRS.size + ] + } + val type = FILE_TYPES[index % FILE_TYPES.size] + val stem = NAME_STEMS[index % NAME_STEMS.size] + val name = "${stem}_${index.toString().padStart(5, '0')}.${type.extension}" + + return LoadFileSpec(directory, name, type.mimeType, sizeFor(index)) + } + + private fun sizeFor(index: Int): Int { + return when (index % 100) { + in 0..9 -> index % 129 + in 10..54 -> 1024 + (index % 4) * 1024 + in 55..84 -> 8192 + (index % 4) * 8192 + in 85..94 -> 65536 + (index % 4) * 32768 + in 95..98 -> 262_144 + else -> 1_048_576 + } + } + + private fun bytesFor(seed: Int, size: Int): ByteArray { + return ByteArray(size) { offset -> ((seed + offset) and 0xff).toByte() } + } + + private fun resetFixtureDirectory() { + saf.shell("rm -rf $FIXTURE_PATH") + saf.shell("mkdir -p $FIXTURE_PATH") + val created = waitForDirectory(FIXTURE_PATH) + assertTrue("Failed to create $FIXTURE_PATH", created) + } + + private fun deleteFixtureDirectory() { + saf.shell("rm -rf $FIXTURE_PATH") + } + + private fun directoryExists(path: String): Boolean { + return saf.shell("ls -d $path").lineSequence().any { line -> + line.trim() == path + } + } + + private fun waitForDirectory(path: String): Boolean { + repeat(10) { + if (directoryExists(path)) return true + SystemClock.sleep(100) + } + return false + } + + private fun fileCount(path: String): Int { + return saf.shell("find $path -type f") + .lineSequence() + .count { line -> line.trim().startsWith(path) } + } + + private fun requireRoot(): DocumentFileCompat { + val file = DocumentFileCompat.fromTreeUri(context, treeUri) + assertNotNull(file) + return file!! + } + + private inline fun timed(label: String, block: () -> T): T { + val start = SystemClock.elapsedRealtime() + return block().also { + Log.i(TAG, "$label took ${SystemClock.elapsedRealtime() - start}ms") + } + } + + private data class FileType( + val extension: String, + val mimeType: String, + ) + + private data class LoadFileSpec( + val directory: String, + val name: String, + val mimeType: String, + val size: Int, + ) + + private companion object { + const val TAG = "DFCLoadTest" + const val ARG_ENABLE_LOAD_TEST = "dfcSafLoad" + const val ARG_SEED_WORKERS = "dfcSafLoadWorkers" + const val FIXTURE_NAME = "DFCQueryPerfFixture" + const val FIXTURE_PATH = "/sdcard/Download/$FIXTURE_NAME" + const val FIXTURE_DOCUMENT_ID = "${SafTestHelper.PRIMARY_ROOT_ID}:Download/$FIXTURE_NAME" + const val ROOT_DIR = "" + const val TOTAL_FILE_COUNT = 5_000 + const val ROOT_FILE_COUNT = 2_000 + const val CATEGORY_FILE_COUNT = 2_000 + const val ROOT_CHILD_COUNT = ROOT_FILE_COUNT + 5 + const val DEFAULT_SEED_WORKERS = 4 + const val MAX_SEED_WORKERS = 8 + + val CATEGORY_DIRS = listOf("docs", "media", "logs", "data", "misc") + val NESTED_DIRS = listOf( + "docs/archive", + "media/camera", + "logs/old", + "data/exports", + "misc/names", + ) + val NAME_STEMS = listOf( + "report", + "invoice", + "notes", + "image", + "clip", + "audio", + "export", + "payload", + "app-log", + "crash-trace", + "spaced name", + "UPPERCASE_NAME", + ) + val FILE_TYPES = listOf( + FileType("txt", "text/plain"), + FileType("log", "text/plain"), + FileType("md", "text/markdown"), + FileType("pdf", "application/pdf"), + FileType("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + FileType("json", "application/json"), + FileType("csv", "text/csv"), + FileType("zip", "application/zip"), + FileType("png", "image/png"), + FileType("jpg", "image/jpeg"), + FileType("mp3", "audio/mpeg"), + FileType("mp4", "video/mp4"), + FileType("bin", "application/octet-stream"), + ) + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt new file mode 100644 index 0000000..f71aede --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt @@ -0,0 +1,147 @@ +package com.lazygeniouz.dfc.testing + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import com.lazygeniouz.dfc.picker.SafTreePickerActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue + +internal class SafTestHelper { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + val context: Context = instrumentation.context + private val device: UiDevice = UiDevice.getInstance(instrumentation) + + fun hasExternalStorageProvider(): Boolean { + return context.packageManager.resolveContentProvider(EXTERNAL_STORAGE_AUTHORITY, 0) != null + } + + fun grantTree(documentId: String): Uri { + SafTreePickerActivity.reset() + val initialUri = DocumentsContract.buildDocumentUri(EXTERNAL_STORAGE_AUTHORITY, documentId) + context.startActivity( + Intent(context, SafTreePickerActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(SafTreePickerActivity.EXTRA_INITIAL_URI, initialUri) + } + ) + + clickUseThisFolder() + clickAllowIfShown() + + val treeUri = SafTreePickerActivity.awaitResult() + assertNotNull(treeUri) + val grantedTreeUri = treeUri!! + assertEquals(EXTERNAL_STORAGE_AUTHORITY, grantedTreeUri.authority) + assertEquals(documentId, DocumentsContract.getTreeDocumentId(grantedTreeUri)) + return grantedTreeUri + } + + fun releaseTree(treeUri: Uri) { + runCatching { + context.contentResolver.releasePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + + fun shell(command: String): String { + return device.executeShellCommand(command) + } + + fun honoredQueryArgs(treeUri: Uri): Set { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri), + ) + val queryArgs = Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + "(${Document.COLUMN_MIME_TYPE} != ?) AND " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + ) + putStringArray( + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, + arrayOf(Document.MIME_TYPE_DIR, "%report%"), + ) + putString( + ContentResolver.QUERY_ARG_SQL_SORT_ORDER, + "${Document.COLUMN_DISPLAY_NAME} ASC", + ) + putInt(ContentResolver.QUERY_ARG_LIMIT, 100) + } + + return runCatching { + context.contentResolver.query( + childrenUri, + arrayOf(Document.COLUMN_DISPLAY_NAME), + queryArgs, + null, + )?.use { cursor -> + cursor.extras.getStringArray(ContentResolver.EXTRA_HONORED_ARGS) + ?.toSet() + .orEmpty() + }.orEmpty() + }.getOrDefault(emptySet()) + } + + private fun clickUseThisFolder() { + waitForDocumentsUi() + val selectButton = device.wait( + Until.findObject(By.res("android", "button1")), + PICKER_TIMEOUT_MS, + ) ?: device.wait(Until.findObject(By.textContains("USE THIS FOLDER")), SHORT_TIMEOUT_MS) + ?: findDocumentsUiObject("action_menu_select", SHORT_TIMEOUT_MS) + ?: throw AssertionError("DocumentsUI select-folder button was not found") + selectButton.click() + } + + private fun waitForDocumentsUi() { + val opened = device.wait( + Until.hasObject(By.pkg(GOOGLE_DOCUMENTS_UI_PACKAGE)), + PICKER_TIMEOUT_MS, + ) || device.wait( + Until.hasObject(By.pkg(AOSP_DOCUMENTS_UI_PACKAGE)), + SHORT_TIMEOUT_MS, + ) + assertTrue("DocumentsUI did not open", opened) + } + + private fun clickAllowIfShown() { + val allowButton = device.wait(Until.findObject(By.textContains("ALLOW")), SHORT_TIMEOUT_MS) + ?: device.wait(Until.findObject(By.textContains("Allow")), SHORT_TIMEOUT_MS) + runCatching { allowButton?.click() } + } + + private fun findDocumentsUiObject(resourceName: String, timeout: Long): UiObject2? { + return device.wait( + Until.findObject(By.res(GOOGLE_DOCUMENTS_UI_PACKAGE, resourceName)), + timeout, + ) ?: device.wait( + Until.findObject(By.res(AOSP_DOCUMENTS_UI_PACKAGE, resourceName)), + SHORT_TIMEOUT_MS, + ) + } + + companion object { + const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" + const val PRIMARY_ROOT_ID = "primary" + + private const val GOOGLE_DOCUMENTS_UI_PACKAGE = "com.google.android.documentsui" + private const val AOSP_DOCUMENTS_UI_PACKAGE = "com.android.documentsui" + private const val PICKER_TIMEOUT_MS = 10_000L + private const val SHORT_TIMEOUT_MS = 2_000L + } +} 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..7d98909 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 @@ -31,12 +32,19 @@ internal class DocumentController( /** * This will return a list of [DocumentFileCompat] with all the defined fields. */ - internal fun listFiles( - projection: Array = ResolverCompat.fullProjection, - ): List { + internal fun listFiles(): List { return if (!isDirectory()) throw UnsupportedOperationException("Selected document is not a Directory.") - else ResolverCompat.listFiles(context, fileCompat, projection) + else ResolverCompat.listFiles(context, fileCompat) + } + + /** + * 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.listFiles(context, fileCompat, *queries) } /** @@ -115,7 +123,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 3537242..b776151 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 /** @@ -67,11 +69,14 @@ abstract class DocumentFileCompat( abstract fun listFiles(): List /** - * Same as [listFiles] but allows specifying a custom [projection] (columns to query). + * List child documents using [Query] clauses. * - * Use custom projection to improve performance by fetching only needed data. + * This is only supported for tree-backed directories. File-backed documents do not evaluate + * provider-level query clauses locally. + * + * @throws UnsupportedOperationException if this document is not a tree-backed directory. */ - abstract fun listFiles(projection: Array): List + abstract fun listFiles(vararg queries: Query): List /** * This will return the children count inside a **Directory** without creating [DocumentFileCompat] objects. @@ -253,5 +258,46 @@ abstract class DocumentFileCompat( val paths = uri.pathSegments return paths.size >= 2 && "tree" == paths[0] } + + @JvmSynthetic + internal fun fromCursor( + 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(it.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)) + val documentSize: Long = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_SIZE)) + val documentLastModified: Long = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_LAST_MODIFIED)) + val documentMimeType: String = + it.getString(it.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)) + val documentFlags: Int = + it.getLong(it.getColumnIndexOrThrow(Document.COLUMN_FLAGS)).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/Query.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt new file mode 100644 index 0000000..36473f6 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/Query.kt @@ -0,0 +1,649 @@ +package com.lazygeniouz.dfc.file + +import android.provider.DocumentsContract.Document + +/** + * Query clauses for [DocumentFileCompat.listFiles]. + * + * For tree-backed SAF directories: + * + * - API 21-25: only [Query.select], [Query.orderByAsc], and [Query.orderByDesc] are forwarded. + * - API 26+: filter queries, [Query.limit], and [Query.offset] are also forwarded. + * + * Unsupported clauses are ignored and logged. Providers may still ignore forwarded clauses. + * Filter values must be null, String, Number, or Boolean. Float and Double values must be finite. + * + * When multiple queries are passed to the same `listFiles(...)` call: + * + * - [Query.select] clauses are unioned. + * - top-level filter clauses are joined with AND. Use [Query.anyOf], [Query.allOf], and + * [Query.not] for grouped filter logic. + * - [Query.orderByAsc] and [Query.orderByDesc] clauses are applied in the order passed. + * - repeated [Query.limit] or [Query.offset] clauses use the last value passed. + */ +class Query private constructor( + private val spec: Spec, +) { + + private class Spec( + val projectionColumns: List? = null, + val sortClause: String? = null, + val limitCount: Int? = null, + val offsetCount: Int? = null, + val selectionPart: Pair>? = null, + val description: String, + ) + + companion object { + + /** + * Fetch the given columns, plus columns required internally to build results. + * + * Use this when combining projection with sort, filter, limit, or offset query clauses. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun select(vararg columns: String): Query { + require(columns.isNotEmpty()) { "select requires at least one column" } + columns.forEach { column -> requireAndroidColumnName(column, "column") } + return projection(*columns) + } + + @JvmSynthetic + internal fun projection(vararg columns: String): Query { + return Query( + Spec( + projectionColumns = columns.toList(), + description = "select", + ), + ) + } + + /** + * Sort ascending by the given column. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun orderByAsc(column: String): Query { + return sortQuery(column, descending = false) + } + + /** + * Sort descending by the given column. + * + * Forwarded on API 21+. + */ + @JvmStatic + fun orderByDesc(column: String): Query { + return sortQuery(column, descending = true) + } + + /** + * Limit the number of returned child documents. + * + * If more than one limit is passed to the same `listFiles(...)` call, the last one wins. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun limit(count: Int): Query { + require(count >= 0) { "limit must be >= 0" } + return Query( + Spec( + limitCount = count, + description = "limit($count)", + ), + ) + } + + /** + * Skip the first [count] child documents. + * + * If more than one offset is passed to the same `listFiles(...)` call, the last one wins. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun offset(count: Int): Query { + require(count >= 0) { "offset must be >= 0" } + return Query( + Spec( + offsetCount = count, + description = "offset($count)", + ), + ) + } + + /** + * Attribute equals [value]. + * + * A null [value] is compiled as `IS NULL`. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun equal(attribute: String, value: Any?): Query { + return if (value == null) isNull(attribute) + else selection(attribute, "equal($attribute)") { column -> + compiledSelection("($column = ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute does not equal [value]. + * + * A null [value] is compiled as `IS NOT NULL`. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun notEqual(attribute: String, value: Any?): Query { + return if (value == null) isNotNull(attribute) + else selection(attribute, "notEqual($attribute)") { column -> + compiledSelection("($column != ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute equals one of [values]. + * + * Null values add an `IS NULL` branch. + * + * 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, "in($attribute)") { column -> + buildInSelection(column, values.toList()) + } + } + + /** + * Attribute does not equal any of [values]. + * + * Null values add an `IS NOT NULL` guard. + * + * 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, "notIn($attribute)") { column -> + buildNotInSelection(column, values.toList()) + } + } + + /** + * Attribute is greater than [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun greaterThan(attribute: String, value: Any): Query { + return selection(attribute, "greaterThan($attribute)") { column -> + compiledSelection("($column > ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is greater than or equal to [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun greaterThanOrEqual(attribute: String, value: Any): Query { + return selection(attribute, "greaterThanOrEqual($attribute)") { column -> + compiledSelection("($column >= ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is less than [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lessThan(attribute: String, value: Any): Query { + return selection(attribute, "lessThan($attribute)") { column -> + compiledSelection("($column < ?)", listOf(value.toSqlArg())) + } + } + + /** + * Attribute is less than or equal to [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lessThanOrEqual(attribute: String, value: Any): Query { + return selection(attribute, "lessThanOrEqual($attribute)") { column -> + compiledSelection("($column <= ?)", listOf(value.toSqlArg())) + } + } + + /** + * 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 ?)", + listOf(start.toSqlArg(), endInclusive.toSqlArg()), + ) + } + } + + /** + * Attribute is null. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun isNull(attribute: String): Query { + return selection(attribute, "isNull($attribute)") { column -> + compiledSelection("($column IS NULL)", emptyList()) + } + } + + /** + * Attribute is not null. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun isNotNull(attribute: String): Query { + return selection(attribute, "isNotNull($attribute)") { column -> + compiledSelection("($column IS NOT NULL)", emptyList()) + } + } + + /** + * Attribute matches the SQL LIKE [pattern]. + * + * The pattern is forwarded as-is. Escape literal `%`, `_`, and `\` yourself, or use + * [nameContains] for display-name contains matching. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun like(attribute: String, pattern: String): Query { + return selection(attribute, "like($attribute)") { column -> + compiledSelection("($column LIKE ? ESCAPE '\\')", listOf(pattern)) + } + } + + /** + * Attribute does not match the SQL LIKE [pattern]. + * + * The pattern is forwarded as-is. Escape literal `%`, `_`, and `\` yourself, or use + * [nameContains] for display-name contains matching. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun notLike(attribute: String, pattern: String): Query { + return selection(attribute, "notLike($attribute)") { column -> + compiledSelection("($column NOT LIKE ? ESCAPE '\\')", listOf(pattern)) + } + } + + /** + * Match documents that satisfy every [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun allOf(vararg filters: Query): Query { + return combineFilters("allOf", "AND", filters) + } + + /** + * Match documents that satisfy at least one [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun anyOf(vararg filters: Query): Query { + return combineFilters("anyOf", "OR", filters) + } + + /** + * Match documents that do not satisfy [filter]. + * + * Only filter queries can be nested. Projection, sort, limit, and offset queries are + * not accepted. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun not(filter: Query): Query { + val selectionPart = requireFilterQuery(filter, "not") + return Query( + Spec( + selectionPart = compiledSelection( + "(NOT ${selectionPart.first})", + selectionPart.second, + ), + description = "not(${describe(filter)})", + ), + ) + } + + /** + * Exclude directories. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun filesOnly(): Query { + return notEqual(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Include only directories. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun directoriesOnly(): Query { + return equal(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR) + } + + /** + * Name equals [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun nameEquals(value: String): Query { + return equal(Document.COLUMN_DISPLAY_NAME, value) + } + + /** + * Name contains [value]. + * + * SQL LIKE wildcards in [value] are escaped before forwarding. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun nameContains(value: String): Query { + return like( + Document.COLUMN_DISPLAY_NAME, + "%${escapeLikePattern(value)}%", + ) + } + + /** + * MIME type equals [value]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun mimeType(value: String): Query { + return equal(Document.COLUMN_MIME_TYPE, value) + } + + /** + * MIME type equals one of [values]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun mimeTypeIn(vararg values: String): Query { + return `in`(Document.COLUMN_MIME_TYPE, *values) + } + + /** + * Size is greater than [bytes]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun sizeGreaterThan(bytes: Long): Query { + return greaterThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Size is less than [bytes]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun sizeLessThan(bytes: Long): Query { + return lessThan(Document.COLUMN_SIZE, bytes) + } + + /** + * Last modified time is after [timestampMillis]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lastModifiedAfter(timestampMillis: Long): Query { + return greaterThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + /** + * Last modified time is before [timestampMillis]. + * + * Forwarded on API 26+. + */ + @JvmStatic + fun lastModifiedBefore(timestampMillis: Long): Query { + return lessThan(Document.COLUMN_LAST_MODIFIED, timestampMillis) + } + + @JvmSynthetic + internal fun projectionColumns(query: Query): List? { + return query.spec.projectionColumns + } + + @JvmSynthetic + internal fun sortClause(query: Query): String? { + return query.spec.sortClause + } + + @JvmSynthetic + internal fun limitCount(query: Query): Int? { + return query.spec.limitCount + } + + @JvmSynthetic + internal fun offsetCount(query: Query): Int? { + return query.spec.offsetCount + } + + @JvmSynthetic + internal fun selectionPart(query: Query): Pair>? { + return query.spec.selectionPart + } + + @JvmSynthetic + internal fun describe(query: Query): String { + return query.spec.description + } + + private fun sortQuery(column: String, descending: Boolean): Query { + requireAndroidColumnName(column, "column") + return Query( + Spec( + sortClause = "$column ${if (descending) "DESC" else "ASC"}", + description = if (descending) { + "orderByDesc($column)" + } else { + "orderByAsc($column)" + }, + ), + ) + } + + private fun selection( + attribute: String, + description: String, + build: (String) -> Pair>, + ): Query { + requireAndroidColumnName(attribute, "attribute") + return Query( + Spec( + selectionPart = build(attribute), + description = description, + ), + ) + } + + private fun escapeLikePattern(value: String): String { + return value + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + } + + private val androidColumnNamePattern = Regex("[A-Za-z_][A-Za-z0-9_]*") + + 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 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() }, + ) + } + } + + private fun combineFilters( + description: String, + operator: String, + filters: Array, + ): Query { + require(filters.isNotEmpty()) { "$description requires at least one filter" } + + val selectionParts = filters.map { filter -> + requireFilterQuery(filter, description) + } + val filterDescription = filters.joinToString { filter -> describe(filter) } + return Query( + Spec( + selectionPart = compiledSelection( + selectionParts.joinToString( + separator = " $operator ", + prefix = "(", + postfix = ")", + ) { selectionPart -> selectionPart.first }, + selectionParts.flatMap { selectionPart -> selectionPart.second }, + ), + description = "$description($filterDescription)", + ), + ) + } + + private fun requireFilterQuery( + query: Query, + parentDescription: String, + ): Pair> { + return query.spec.selectionPart ?: throw IllegalArgumentException( + "$parentDescription accepts only filter queries." + ) + } + + private fun compiledSelection( + selection: String, + args: List, + ): Pair> { + return selection to args + } + + private fun Any?.toSqlArg(): String { + return when (this) { + null -> "null" + is String -> this + is Boolean -> if (this) "1" else "0" + 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/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/RawDocumentFileCompat.kt index bb20f65..432e42f 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 @@ -103,13 +104,10 @@ internal class RawDocumentFileCompat(context: Context, var file: File) : return file.listFiles()?.map { child -> fromFile(context, child) } ?: emptyList() } - /** - * Returns list of files using File API. - * - * Note: [projection] is ignored as the File API doesn't support it. - */ - override fun listFiles(projection: Array): List { - return listFiles() + 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/SingleDocumentFileCompat.kt b/dfc/src/main/java/com/lazygeniouz/dfc/file/internals/SingleDocumentFileCompat.kt index c2552c7..431a033 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 /** @@ -50,13 +51,10 @@ internal class SingleDocumentFileCompat( throw UnsupportedOperationException() } - /** - * Single document Uris don't have children, so projections are not applicable. - * - * @throws UnsupportedOperationException - */ - override fun listFiles(projection: Array): List { - return listFiles() + override fun listFiles(vararg queries: Query): List { + throw UnsupportedOperationException( + "Queries are only supported for DocumentsProvider-backed tree URIs." + ) } /** @@ -113,18 +111,15 @@ 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( + ?.let { cursor -> + return DocumentFileCompat.fromCursor( + cursor, + "Exception while building a single document file", + ) { name, size, lastModified, mimeType, flags -> + SingleDocumentFileCompat( context, self, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags + name, size, + lastModified, mimeType, flags, ) } } 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..169cca4 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 /** @@ -50,17 +51,14 @@ internal class TreeDocumentFileCompat( return treeFileUri?.let { make(context, treeFileUri, false) } } - /** - * This will return a list of [DocumentFileCompat] with the required fields based on the passed [projection]. - */ - override fun listFiles(projection: Array): List { - return fileController.listFiles(projection) - } - override fun listFiles(): List { return fileController.listFiles() } + override fun listFiles(vararg queries: Query): List { + return fileController.listFiles(*queries) + } + /** * This will return the children count in the directory. * @@ -124,18 +122,15 @@ 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( + ?.let { cursor -> + return DocumentFileCompat.fromCursor( + cursor, + "Exception while building a tree document file", + ) { name, size, lastModified, mimeType, flags -> + TreeDocumentFileCompat( context, treeUri, - documentName, documentSize, - documentLastModified, documentMimeType, documentFlags + name, size, + lastModified, mimeType, flags, ) } } 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..7bca777 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}") } + + /** + * Log warning to logcat for non-fatal behavior differences. + */ + 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 def6858..44b6e3b 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,12 @@ 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.internals.SingleDocumentFileCompat import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat import com.lazygeniouz.dfc.logger.ErrorLogger @@ -93,11 +96,18 @@ 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 + val cursor = getCursor(context, childrenUri, iconProjection) ?: return 0 + return count(cursor) + } + + @JvmSynthetic + internal fun count(cursor: Cursor): Int { + return try { + cursor.use { it.count } + } catch (exception: Exception) { + ErrorLogger.logError("Exception while counting child documents", exception) + 0 + } } /** @@ -115,104 +125,257 @@ internal object ResolverCompat { } /** - * Queries the ContentResolver & builds a list of [DocumentFileCompat] with all the required fields. + * Queries the ContentResolver using provider-level query arguments and builds + * a list of [DocumentFileCompat]. */ internal fun listFiles( context: Context, file: DocumentFileCompat, - projection: Array = fullProjection, + vararg queries: Query, ): List { val uri = file.uri val childrenUri = createChildrenUri(uri) - val listOfDocuments = arrayListOf() + 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) + add(Document.COLUMN_MIME_TYPE) - val finalProjection = arrayOf( - Document.COLUMN_DOCUMENT_ID, /* identifier */ - Document.COLUMN_MIME_TYPE, /* for supporting rename via `isDirectory` check */ - *projection - ).distinct().toTypedArray() - - val cursor = getCursor(context, childrenUri, finalProjection) ?: return emptyList() - - 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) - - // 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) - val mimeIndex = cursor.getColumnIndex(Document.COLUMN_MIME_TYPE) - val flagsIndex = cursor.getColumnIndex(Document.COLUMN_FLAGS) - - while (cursor.moveToNext()) { - val documentId = cursor.getString(idIndex) ?: continue - val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) - - val documentName = getStringOrDefault(cursor, nameIndex) - val documentSize = getLongOrDefault(cursor, sizeIndex) - val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L) - val documentMimeType = getStringOrDefault(cursor, mimeIndex) + if (projectionQueries.isEmpty()) { + addAll(fullProjection) + } else { + projectionQueries.forEach { addAll(it) } + } - /** - * 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) + // Required internally for capability checks like canWrite() and isVirtual(). + add(Document.COLUMN_FLAGS) + }.toTypedArray() + + val ignoredQueries = mutableListOf() + val selectionParts = mutableListOf() + val selectionArgs = mutableListOf() + val sortClauses = mutableListOf() + + var limit: Int? = null + var offset: Int? = null + + queries.forEach { query -> + if (Query.projectionColumns(query) != null) return@forEach + + val sortClause = Query.sortClause(query) + if (sortClause != null) { + sortClauses += sortClause + return@forEach + } + + 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(query) + if (offsetCount != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) offset = offsetCount + else ignoredQueries += query + return@forEach + } + + val selectionPart = Query.selectionPart(query) + if (selectionPart != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + selectionParts += selectionPart.first + selectionArgs += selectionPart.second + } else { + ignoredQueries += query + } + return@forEach + } + } + + 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 } - return listOfDocuments + 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]. */ - fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { + internal fun getCursor(context: Context, uri: Uri, projection: Array): Cursor? { + return getCursor(context, uri, projection, null, null, null, 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 { - context.contentResolver.query( - uri, projection, null, null, null - ) + 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) { - /** - * 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 } } + 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 { Query.describe(it) }) + append(". SAF child-document filtering, limit, and offset require API 26+.") + } + ) + } + + @JvmSynthetic + internal fun buildDocumentList( + context: Context, + file: DocumentFileCompat, + treeUri: Uri, + cursor: Cursor, + ): List { + val listOfDocuments = arrayListOf() + + 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) + + 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) + + 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) + } + } + + listOfDocuments + } catch (exception: Exception) { + ErrorLogger.logError("Exception while building child document list", exception) + emptyList() + } + } + // Make children uri for query. private fun createChildrenUri(uri: Uri): Uri { return DocumentsContract.buildChildDocumentsUriUsingTree( 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..5c3ec00 --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/DocumentFileCompatJavaApiTest.java @@ -0,0 +1,88 @@ +package com.lazygeniouz.dfc.file; + +import android.provider.DocumentsContract.Document; +import java.lang.reflect.Constructor; +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.in(Document.COLUMN_MIME_TYPE, "image/png", "image/jpeg"), + Query.notIn(Document.COLUMN_MIME_TYPE, "application/pdf"), + Query.anyOf(Query.mimeType("image/png"), Query.mimeType("image/jpeg")), + Query.allOf(Query.filesOnly(), Query.sizeGreaterThan(0L)), + Query.not(Query.directoriesOnly()), + Query.limit(100), + }; + + assertEquals(8, queries.length); + } + + @SuppressWarnings("unused") + private static void listFilesOverloadsAreCallableFromJavaSource(DocumentFileCompat file) { + file.listFiles(); + 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 }) + ) + ); + assertFalse( + Arrays.stream(methods).anyMatch(method -> + method.getName().equals("listFiles") + && Arrays.equals(method.getParameterTypes(), new Class[] { String[].class }) + ) + ); + } + + @Test + public void internalCursorHelperIsSyntheticOnJavaSide() { + Method[] methods = DocumentFileCompat.Companion.getClass().getMethods(); + + assertTrue( + Arrays.stream(methods).anyMatch(method -> + method.getName().startsWith("fromCursor") && method.isSynthetic() + ) + ); + assertFalse( + Arrays.stream(methods).anyMatch(method -> + method.getName().equals("fromCursor") && !method.isSynthetic() + ) + ); + } + + @Test + public void queryInternalApiIsSyntheticOnJavaSide() { + Method[] methods = Query.Companion.getClass().getMethods(); + + assertTrue( + Arrays.stream(methods).anyMatch(method -> + method.getName().startsWith("projectionColumns") && method.isSynthetic() + ) + ); + assertTrue( + Arrays.stream(Query.class.getConstructors()).allMatch(Constructor::isSynthetic) + ); + } +} 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..d43da1e --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt @@ -0,0 +1,456 @@ +package com.lazygeniouz.dfc.file + +import android.provider.DocumentsContract.Document +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueryTest { + + @Test + fun `nameContains escapes sql like wildcards`() { + val selectionPart = Query.nameContains("100%_done\\ready").selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("%100\\%\\_done\\\\ready%"), selectionPart.second) + } + + @Test + fun `in with null adds is null clause`() { + 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))", + selectionPart.first, + ) + assertEquals(listOf("image/png"), selectionPart.second) + } + + @Test + fun `notIn with null adds is not null clause`() { + 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))", + selectionPart.first, + ) + assertEquals(listOf("image/png"), selectionPart.second) + } + + @Test + fun `in with only null compiles to is null`() { + val selectionPart = Query.`in`(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `notIn with only null compiles to is not null`() { + val selectionPart = Query.notIn(Document.COLUMN_MIME_TYPE, null).selectionPart()!! + + assertEquals("(${Document.COLUMN_MIME_TYPE} IS NOT NULL)", selectionPart.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @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.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.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `equal compiles to equality selection`() { + val selectionPart = Query.equal(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} = ?)", selectionPart.first) + assertEquals(listOf("report.pdf"), selectionPart.second) + } + + @Test + fun `notEqual compiles to inequality selection`() { + val selectionPart = Query.notEqual(Document.COLUMN_DISPLAY_NAME, "report.pdf") + .selectionPart()!! + + assertEquals("(${Document.COLUMN_DISPLAY_NAME} != ?)", selectionPart.first) + assertEquals(listOf("report.pdf"), selectionPart.second) + } + + @Test + fun `boolean values compile to numeric sql args`() { + val selectionPart = Query.equal(Document.COLUMN_FLAGS, true).selectionPart()!! + + assertEquals("(${Document.COLUMN_FLAGS} = ?)", selectionPart.first) + assertEquals(listOf("1"), selectionPart.second) + } + + @Test + fun `greaterThan compiles correctly`() { + val selectionPart = Query.greaterThan(Document.COLUMN_SIZE, 1024L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} > ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `greaterThanOrEqual compiles correctly`() { + val selectionPart = Query.greaterThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! + + 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.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `lessThanOrEqual compiles correctly`() { + val selectionPart = Query.lessThanOrEqual(Document.COLUMN_SIZE, 1024L) + .selectionPart()!! + + 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.first) + 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()!! + + 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.first) + assertTrue(selectionPart.second.isEmpty()) + } + + @Test + fun `like compiles correctly`() { + val selectionPart = Query.like(Document.COLUMN_DISPLAY_NAME, "report%").selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("report%"), selectionPart.second) + } + + @Test + fun `notLike compiles correctly`() { + val selectionPart = Query.notLike(Document.COLUMN_DISPLAY_NAME, "report%") + .selectionPart()!! + + assertEquals( + "(${Document.COLUMN_DISPLAY_NAME} NOT LIKE ? ESCAPE '\\')", + selectionPart.first, + ) + assertEquals(listOf("report%"), selectionPart.second) + } + + @Test + fun `allOf compiles grouped and selection`() { + val selectionPart = Query.allOf( + Query.filesOnly(), + Query.sizeGreaterThan(0L), + ).selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?))", + selectionPart.first, + ) + assertEquals(listOf(Document.MIME_TYPE_DIR, "0"), selectionPart.second) + } + + @Test + fun `anyOf compiles grouped or selection`() { + val selectionPart = Query.anyOf( + Query.mimeType("image/png"), + Query.nameContains("report"), + ).selectionPart()!! + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\'))", + selectionPart.first, + ) + assertEquals(listOf("image/png", "%report%"), selectionPart.second) + } + + @Test + fun `not compiles grouped negation selection`() { + val selectionPart = Query.not(Query.directoriesOnly()).selectionPart()!! + + assertEquals("(NOT (${Document.COLUMN_MIME_TYPE} = ?))", selectionPart.first) + assertEquals(listOf(Document.MIME_TYPE_DIR), selectionPart.second) + } + + @Test + fun `grouped filters can nest`() { + val selectionPart = Query.allOf( + Query.anyOf( + Query.nameEquals("a.txt"), + Query.nameEquals("b.txt"), + ), + Query.not(Query.directoriesOnly()), + ).selectionPart()!! + + assertEquals( + "(((${Document.COLUMN_DISPLAY_NAME} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} = ?)) AND " + + "(NOT (${Document.COLUMN_MIME_TYPE} = ?)))", + selectionPart.first, + ) + assertEquals(listOf("a.txt", "b.txt", Document.MIME_TYPE_DIR), selectionPart.second) + } + + @Test + fun `filesOnly maps to mime type not equal directory`() { + val selectionPart = Query.filesOnly().selectionPart()!! + + 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.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.first) + assertEquals(listOf("image/png", "image/jpeg"), selectionPart.second) + } + + @Test + fun `sizeLessThan maps to size less than selection`() { + val selectionPart = Query.sizeLessThan(1024L).selectionPart()!! + + assertEquals("(${Document.COLUMN_SIZE} < ?)", selectionPart.first) + assertEquals(listOf("1024"), selectionPart.second) + } + + @Test + fun `lastModifiedAfter maps to last modified greater than selection`() { + val selectionPart = Query.lastModifiedAfter(123L).selectionPart()!! + + assertEquals("(${Document.COLUMN_LAST_MODIFIED} > ?)", selectionPart.first) + assertEquals(listOf("123"), selectionPart.second) + } + + @Test + fun `lastModifiedBefore maps to last modified less than selection`() { + val selectionPart = Query.lastModifiedBefore(456L).selectionPart()!! + + assertEquals("(${Document.COLUMN_LAST_MODIFIED} < ?)", selectionPart.first) + assertEquals(listOf("456"), selectionPart.second) + } + + @Test + fun `select returns projection query`() { + val query = Query.select(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( + "${Document.COLUMN_DISPLAY_NAME} ASC", + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) + } + + @Test + fun `orderByDesc returns descending sort query`() { + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} DESC", + Query.orderByDesc(Document.COLUMN_DISPLAY_NAME).sortClause(), + ) + } + + @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`() { + assertEquals(25, Query.limit(25).limitCount()) + } + + @Test + fun `offset returns offset query`() { + assertEquals(10, Query.offset(10).offsetCount()) + } + + @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 `select rejects empty columns`() { + Query.select() + } + + @Test(expected = IllegalArgumentException::class) + fun `allOf rejects empty filters`() { + Query.allOf() + } + + @Test(expected = IllegalArgumentException::class) + fun `anyOf rejects empty filters`() { + Query.anyOf() + } + + @Test(expected = IllegalArgumentException::class) + fun `allOf rejects non filter query`() { + Query.allOf(Query.select(Document.COLUMN_DISPLAY_NAME)) + } + + @Test(expected = IllegalArgumentException::class) + fun `anyOf rejects non filter query`() { + Query.anyOf(Query.orderByAsc(Document.COLUMN_DISPLAY_NAME)) + } + + @Test(expected = IllegalArgumentException::class) + fun `not rejects non filter query`() { + Query.not(Query.limit(1)) + } + + @Test(expected = IllegalArgumentException::class) + fun `orderByAsc rejects unsafe column name`() { + 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") + } + + @Test(expected = IllegalArgumentException::class) + 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? { + 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) +} + +@Suppress("unused") +private fun listFilesOverloadsAreCallableFromKotlinSource(file: DocumentFileCompat) { + file.listFiles() + file.listFiles(Query.select(Document.COLUMN_DISPLAY_NAME)) + file.listFiles(Query.limit(1)) +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt new file mode 100644 index 0000000..55b235a --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/internals/DocumentFileCompatFromCursorTest.kt @@ -0,0 +1,117 @@ +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.file.DocumentFileCompat +import com.lazygeniouz.dfc.resolver.ResolverCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +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 DocumentFileCompatFromCursorTest { + + @Test + fun `fromCursor reads columns by name`() { + val context = RuntimeEnvironment.getApplication() + val uri = Uri.parse("content://com.lazygeniouz.dfc.test.documents/document/root%2Fnotes.txt") + + val file = DocumentFileCompat.fromCursor( + shuffledDocumentCursor(), + "test", + ) { name, size, lastModified, mime, flags -> + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + + assertNotNull(file) + assertEquals("notes.txt", file!!.name) + assertEquals(128L, file.length) + assertEquals(7L, file.lastModified) + assertEquals("text/plain", file.getType()) + assertEquals(Document.FLAG_SUPPORTS_WRITE, file.documentFlags) + } + + @Test + fun `single fromCursor 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( + DocumentFileCompat.fromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) + } + + @Test + fun `tree fromCursor 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( + DocumentFileCompat.fromCursor(cursor, "test") { name, size, lastModified, mime, flags -> + TreeDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + ) + } + + 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") + } + } + } + + private fun shuffledDocumentCursor(): Cursor { + return MatrixCursor( + arrayOf( + Document.COLUMN_FLAGS, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_DOCUMENT_ID, + ) + ).apply { + addRow( + arrayOf( + Document.FLAG_SUPPORTS_WRITE, + 7L, + "text/plain", + 128L, + "notes.txt", + "root/notes.txt", + ) + ) + } + } +} diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt new file mode 100644 index 0000000..8fe8afa --- /dev/null +++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt @@ -0,0 +1,655 @@ +package com.lazygeniouz.dfc.resolver + +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 +import android.os.Bundle +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsProvider +import com.lazygeniouz.dfc.file.Query +import com.lazygeniouz.dfc.file.internals.RawDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat +import java.io.FileNotFoundException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class ResolverCompatQueryTest { + + private lateinit var provider: TestDocumentsProvider + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + ShadowContentResolver.reset() + provider = TestDocumentsProvider() + provider.attachInfo( + context, + ProviderInfo().apply { + authority = TestDocumentsProvider.AUTHORITY + name = TestDocumentsProvider::class.java.name + exported = true + grantUriPermissions = true + readPermission = Manifest.permission.MANAGE_DOCUMENTS + writePermission = Manifest.permission.MANAGE_DOCUMENTS + }, + ) + ShadowContentResolver.registerProviderInternal(TestDocumentsProvider.AUTHORITY, provider) + } + + @Test + fun `query forwards api 26 bundle arguments`() { + 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.filesOnly(), + Query.limit(1), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + assertEquals( + "(${Document.COLUMN_MIME_TYPE} != ?)", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR), + 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, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + + val file = children.first { it.name == "notes.txt" } + val directory = children.first { it.name == "photos" } + + assertTrue(file.isFile()) + assertFalse(file.isDirectory()) + assertEquals("text/plain", file.getType()) + assertEquals(Document.FLAG_SUPPORTS_WRITE, file.documentFlags) + assertSame(root, file.parentFile) + + assertTrue(directory.isDirectory()) + assertFalse(directory.isFile()) + assertNull(directory.getType()) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, directory.documentFlags) + assertSame(root, directory.parentFile) + } + + @Test + fun `query forwards api 26 sort order bundle argument`() { + 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.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} ASC", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER), + ) + } + + @Test + fun `query unions multiple select clauses`() { + 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.select(Document.COLUMN_DISPLAY_NAME), + Query.select(Document.COLUMN_SIZE, Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_SIZE, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + } + + @Test + fun `query forwards compound sort order in query order`() { + 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.orderByDesc(Document.COLUMN_LAST_MODIFIED), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + "${Document.COLUMN_LAST_MODIFIED} DESC, ${Document.COLUMN_DISPLAY_NAME} ASC", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER), + ) + } + + @Test + fun `query repeated limit and offset use last values`() { + 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.limit(10), + Query.offset(5), + Query.limit(2), + Query.offset(1), + ) + + assertEquals(2, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_LIMIT)) + assertEquals(1, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_OFFSET)) + } + + @Test + fun `query joins api 26 filter bundle arguments with and`() { + 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.filesOnly(), + Query.sizeGreaterThan(0L), + ) + + assertEquals( + "(${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?)", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR, "0"), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + + @Test + fun `query forwards grouped allOf filter bundle arguments`() { + 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.allOf( + Query.filesOnly(), + Query.sizeGreaterThan(0L), + ), + ) + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} != ?) AND (${Document.COLUMN_SIZE} > ?))", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf(Document.MIME_TYPE_DIR, "0"), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + + @Test + fun `query forwards grouped anyOf and not filter bundle arguments`() { + 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.anyOf( + Query.mimeType("image/png"), + Query.nameContains("report"), + ), + Query.not(Query.directoriesOnly()), + ) + + assertEquals( + "((${Document.COLUMN_MIME_TYPE} = ?) OR " + + "(${Document.COLUMN_DISPLAY_NAME} LIKE ? ESCAPE '\\')) AND " + + "(NOT (${Document.COLUMN_MIME_TYPE} = ?))", + provider.lastQueryArgs?.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), + ) + assertArrayEquals( + arrayOf("image/png", "%report%", Document.MIME_TYPE_DIR), + provider.lastQueryArgs?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS), + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `query falls back to legacy sort only before api 26`() { + 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.filesOnly(), + Query.limit(1), + Query.orderByDesc(Document.COLUMN_DISPLAY_NAME), + ) + + assertEquals( + listOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, + ), + provider.lastChildProjection?.toList(), + ) + assertNull(provider.lastQueryArgs) + assertNull(provider.lastLegacySelection) + assertNull(provider.lastLegacySelectionArgs) + assertEquals( + "${Document.COLUMN_DISPLAY_NAME} DESC", + provider.lastLegacySortOrder, + ) + assertEquals(2, children.size) + } + + @Test + fun `query returns empty list when provider omits required document id 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.omitDocumentIdColumn = true + + val children = root.listFiles( + Query.select(Document.COLUMN_DISPLAY_NAME), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + 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), + Query.orderByAsc(Document.COLUMN_DISPLAY_NAME), + ) + + assertTrue(children.isEmpty()) + } + + @Test + fun `query forwards 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.offset(2)) + + assertEquals(2, provider.lastQueryArgs?.getInt(ContentResolver.QUERY_ARG_OFFSET)) + } + + @Test + fun `count returns zero when child cursor count throws`() { + val cursor = throwingCursor(childCursor(), throwOnCount = true) + + assertEquals(0, ResolverCompat.count(cursor)) + } + + @Test + fun `build document list 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, + ) + val cursor = throwingCursor(childCursor(), throwOnGetString = true) + + val children = ResolverCompat.buildDocumentList(context, root, root.uri, cursor) + + assertTrue(children.isEmpty()) + } + + @Test + fun `query listFiles rejects non-directory tree documents`() { + val context = RuntimeEnvironment.getApplication() + val file = TreeDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "notes.txt", + documentMimeType = "text/plain", + ) + + try { + file.listFiles(Query.limit(1)) + fail("Expected UnsupportedOperationException") + } catch (exception: UnsupportedOperationException) { + assertEquals("Selected document is not a Directory.", exception.message) + } + } + + @Test + fun `query listFiles rejects single and raw documents`() { + val context = RuntimeEnvironment.getApplication() + val single = SingleDocumentFileCompat( + context = context, + documentUri = TestDocumentsProvider.rootDocumentUri(), + documentName = "notes.txt", + documentMimeType = "text/plain", + ) + val raw = RawDocumentFileCompat(context, context.cacheDir) + + listOf(single, raw).forEach { file -> + try { + file.listFiles(Query.limit(1)) + fail("Expected UnsupportedOperationException") + } catch (exception: UnsupportedOperationException) { + assertEquals( + "Queries are only supported for DocumentsProvider-backed tree URIs.", + exception.message, + ) + } + } + } + + private class TestDocumentsProvider : DocumentsProvider() { + + var lastChildProjection: Array? = null + private set + + var lastQueryArgs: Bundle? = null + private set + + var lastLegacySelection: String? = null + private set + + var lastLegacySelectionArgs: Array? = null + private set + + var lastLegacySortOrder: String? = null + private set + + var omitDocumentIdColumn: Boolean = false + + var omitMimeTypeColumn: Boolean = false + + private val documents = listOf( + TestDocument( + id = ROOT_ID, + name = "root", + mimeType = Document.MIME_TYPE_DIR, + flags = Document.FLAG_DIR_SUPPORTS_CREATE, + ), + TestDocument( + id = FILE_ID, + name = "notes.txt", + mimeType = "text/plain", + size = 128L, + flags = Document.FLAG_SUPPORTS_WRITE, + ), + TestDocument( + id = DIR_ID, + name = "photos", + mimeType = Document.MIME_TYPE_DIR, + flags = Document.FLAG_DIR_SUPPORTS_CREATE, + ), + ) + + override fun onCreate(): Boolean = true + + override fun queryRoots(projection: Array?): Cursor { + return MatrixCursor(projection ?: emptyArray()) + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val document = documents.firstOrNull { it.id == documentId } + ?: throw FileNotFoundException(documentId) + + return cursorOf(projection, listOf(document)) + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String?, + ): Cursor { + lastChildProjection = projection?.copyProjection() + lastLegacySelection = null + lastLegacySelectionArgs = null + lastLegacySortOrder = sortOrder + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + queryArgs: Bundle?, + ): Cursor { + lastChildProjection = projection?.copyProjection() + lastQueryArgs = queryArgs?.let(::Bundle) + return cursorOf(projection, documents.filterNot { it.id == ROOT_ID }) + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + throw FileNotFoundException(documentId) + } + + private fun cursorOf( + projection: Array?, + documents: List, + ): 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) }) + } + } + } + + private data class TestDocument( + val id: String, + val name: String, + val mimeType: String, + val size: Long = 0L, + val lastModified: Long = 0L, + val flags: Int = 0, + ) { + + fun valueFor(column: String): Any? { + return when (column) { + Document.COLUMN_DOCUMENT_ID -> id + Document.COLUMN_DISPLAY_NAME -> name + Document.COLUMN_SIZE -> size + Document.COLUMN_LAST_MODIFIED -> lastModified + Document.COLUMN_MIME_TYPE -> mimeType + Document.COLUMN_FLAGS -> flags + else -> null + } + } + } + + private fun Array.copyProjection(): Array { + return Array(size) { index -> this[index] } + } + + companion object { + const val AUTHORITY = "com.lazygeniouz.dfc.test.documents" + private const val ROOT_ID = "root" + private const val FILE_ID = "root/notes.txt" + private const val DIR_ID = "root/photos" + + fun rootDocumentUri(): Uri { + return DocumentsContract.buildDocumentUriUsingTree( + DocumentsContract.buildTreeDocumentUri(AUTHORITY, ROOT_ID), + ROOT_ID, + ) + } + } + } +} + +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) + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7c20557..aa21dc1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Fri Feb 06 19:51:09 IST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME