diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5951b8e..183474b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -23,6 +23,7 @@ on:
jobs:
build:
+ name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -37,3 +38,34 @@ jobs:
- 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/dfc/build.gradle b/dfc/build.gradle
index 3f7bc38..39f810d 100644
--- a/dfc/build.gradle
+++ b/dfc/build.gradle
@@ -11,6 +11,7 @@ android {
defaultConfig {
minSdk = 21
targetSdk = 36
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -29,4 +30,9 @@ android {
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/test/java/com/lazygeniouz/dfc/file/QueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt
index 9a1d52c..d43da1e 100644
--- a/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt
+++ b/dfc/src/test/java/com/lazygeniouz/dfc/file/QueryTest.kt
@@ -42,6 +42,22 @@ class QueryTest {
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()!!
@@ -76,6 +92,14 @@ class QueryTest {
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()!!
@@ -242,6 +266,30 @@ class QueryTest {
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)
diff --git a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt
index 248d3c5..8fe8afa 100644
--- a/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt
+++ b/dfc/src/test/java/com/lazygeniouz/dfc/resolver/ResolverCompatQueryTest.kt
@@ -160,6 +160,79 @@ class ResolverCompatQueryTest {
)
}
+ @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()