From 5d29ee9b85b36bf68ed72a270fe9adca28dc177b Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 15:34:46 +0530 Subject: [PATCH 1/7] test: add SAF query android coverage --- dfc/build.gradle | 6 + dfc/src/androidTest/AndroidManifest.xml | 9 + .../dfc/picker/SafTreePickerActivity.kt | 84 ++++ .../dfc/query/SafQueryAndroidTest.kt | 213 +++++++++++ .../dfc/query/SafQueryLoadAndroidTest.kt | 359 ++++++++++++++++++ .../lazygeniouz/dfc/testing/SafTestHelper.kt | 147 +++++++ .../com/lazygeniouz/dfc/file/QueryTest.kt | 48 +++ .../dfc/resolver/ResolverCompatQueryTest.kt | 73 ++++ 8 files changed, 939 insertions(+) create mode 100644 dfc/src/androidTest/AndroidManifest.xml create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/picker/SafTreePickerActivity.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryAndroidTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/query/SafQueryLoadAndroidTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/testing/SafTestHelper.kt 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() From 7955fae6c0a7bcdf37f5068b2d4cdfd7450945a5 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 15:37:40 +0530 Subject: [PATCH 2/7] ci: run SAF query connected tests --- .github/workflows/build.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5951b8e..36bb827 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,3 +37,33 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug + + connected-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 connected 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 From cb6f10f459997f5ea9e1e79308874616b0769be1 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:05:40 +0530 Subject: [PATCH 3/7] ci: rename android instrumentation check --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36bb827..f96f472 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - connected-tests: + android-instrumentation-tests: runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -58,7 +58,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Run connected tests + - name: Run Android instrumentation tests uses: ReactiveCircus/android-emulator-runner@v2 with: api-level: 35 From e0437a70b8b9eccc41c89a6afffef636a00d7bee Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:08:35 +0530 Subject: [PATCH 4/7] ci: name android instrumentation job --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f96f472..799cf01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,8 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - android-instrumentation-tests: + connected-tests: + name: Android instrumentation tests runs-on: ubuntu-latest timeout-minutes: 20 steps: From 8a22158181c62b2c5c58afc0f6a6e286ab7c57d4 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:09:56 +0530 Subject: [PATCH 5/7] ci: name build job --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 799cf01..d849633 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 From 19c25e82ddfa2339006f6a2a9fbe2138aa4f5fe0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:25:12 +0530 Subject: [PATCH 6/7] ci: keep required build check name --- .github/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d849633..f96f472 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,6 @@ on: jobs: build: - name: Build runs-on: ubuntu-latest steps: - name: Checkout @@ -39,8 +38,7 @@ jobs: - name: Build run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug - connected-tests: - name: Android instrumentation tests + android-instrumentation-tests: runs-on: ubuntu-latest timeout-minutes: 20 steps: From b8020b7f8ff46b85290c230cd1e82808fa60c52a Mon Sep 17 00:00:00 2001 From: Darshan Date: Sun, 2 Aug 2026 16:29:07 +0530 Subject: [PATCH 7/7] ci: use readable check names --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f96f472..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 @@ -39,6 +40,7 @@ jobs: run: ./gradlew :dfc:assemble :dfc:testDebugUnitTest :app:assembleDebug android-instrumentation-tests: + name: Instrumentation Tests runs-on: ubuntu-latest timeout-minutes: 20 steps: