Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ on:

jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand All @@ -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
6 changes: 6 additions & 0 deletions dfc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ android {
defaultConfig {
minSdk = 21
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
Expand All @@ -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"
}
9 changes: 9 additions & 0 deletions dfc/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<activity
android:name="com.lazygeniouz.dfc.picker.SafTreePickerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<Uri?>()
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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_"
}
}
Loading
Loading