diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c0cd48d..d2a4879 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,29 +1,45 @@
name: Build source
+permissions:
+ contents: read
+
on:
workflow_dispatch:
pull_request:
types: [ opened, synchronize, reopened ]
paths:
- "dfc/**"
+ - "app/**"
+ - ".github/workflows/build.yml"
+ - "*.gradle"
+ - "gradle/**"
+ - "gradle.properties"
push:
branches: [ master ]
paths:
- "dfc/**"
+ - "app/**"
+ - ".github/workflows/build.yml"
+ - "*.gradle"
+ - "gradle/**"
+ - "gradle.properties"
jobs:
build:
+ name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
- name: Set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: "temurin"
java-version: "17"
cache: "gradle"
- - name: Build DFC
- run: ./gradlew :dfc:assemble
+ - name: Build project
+ run: ./gradlew :dfc:assemble :dfc:assembleDebugAndroidTest :app:assembleDebug
diff --git a/README.md b/README.md
index b45e965..44643ac 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +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.
+- Event-driven observation of a directory's direct children.
- Custom projections for lighter queries.
- Convenience APIs like `count()`, `copyTo(destination)`, and `copyFrom(source)`.
@@ -70,6 +71,32 @@ Other entry points:
Additional helpers like `count()`, `copyTo(destination)`, `copyFrom(source)`, and
`listFiles(projection)` are available when you need them.
+### Observe a directory
+
+`observe()` watches the direct children of a SAF tree directory without polling:
+
+```kotlin
+val observer = directory.observe { event, document ->
+ println("$event: ${document.name}")
+}
+
+observer.startWatching(
+ onError = { error -> println("Observation stopped: $error") },
+ onReady = { println("Initial snapshot is ready") },
+)
+
+// Later, from the owning lifecycle:
+observer.close()
+```
+
+Files found during the initial scan aren't emitted. `onReady` runs after that scan, and `onError`
+reports why watching stopped. Callbacks run on a worker thread; close the observer with its
+lifecycle.
+
+Observation depends on provider change notifications. Each refresh scans all direct children, and
+rapid changes may be combined. Move events require stable document IDs; otherwise renames arrive as
+delete and create events, which rename tracking should also handle.
+
## Performance
The sample app includes simple comparisons against AndroidX `DocumentFile`. Results depend on the
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fdeb9c4..ace8e2e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,12 @@
android:theme="@style/Theme.FileCompat"
tools:ignore="AllowBackup">
+
+
()
+ private val timestampFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
+
+ private var directoryUri: Uri? = null
+ private var directory: DocumentFileCompat? = null
+ private var observer: DirectoryObserver? = null
+ private var openingJob: Job? = null
+ private var demoJob: Job? = null
+ private var openingDirectory = false
+ private var shouldObserve = false
+ private var observerReady = false
+ private var demoRunning = false
+ private var openingGeneration = 0L
+ private var observerGeneration = 0L
+
+ private val directoryPicker = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree()
+ ) { uri ->
+ if (uri != null) {
+ persistPermission(uri)
+ openDirectory(uri)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ scroll = findViewById(R.id.observerScroll)
+ status = findViewById(R.id.observerStatus)
+ selectedDirectory = findViewById(R.id.observerDirectory)
+ eventLog = findViewById(R.id.observerEventLog)
+ selectButton = findViewById(R.id.selectObserverDirectory)
+ toggleButton = findViewById(R.id.toggleObserver)
+ demoButton = findViewById(R.id.runObserverDemo)
+ clearButton = findViewById(R.id.clearObserverLog)
+ progress = findViewById(R.id.observerProgress)
+
+ applyEdgeToEdgeInsets()
+
+ renderLog()
+ updateControls()
+
+ selectButton.setOnClickListener {
+ directoryPicker.launch(directoryUri)
+ }
+ toggleButton.setOnClickListener {
+ if (observer == null) startObserver() else stopObserver()
+ }
+ demoButton.setOnClickListener { runDemo() }
+ clearButton.setOnClickListener {
+ logLines.clear()
+ renderLog()
+ }
+
+ savedInstanceState?.getString(STATE_DIRECTORY_URI)?.let { savedUri ->
+ openDirectory(
+ savedUri.toUri(),
+ savedInstanceState.getBoolean(STATE_SHOULD_OBSERVE)
+ )
+ }
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ finish()
+ return true
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putString(STATE_DIRECTORY_URI, directoryUri?.toString())
+ outState.putBoolean(STATE_SHOULD_OBSERVE, shouldObserve)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onDestroy() {
+ openingGeneration++
+ openingJob?.cancel()
+ demoJob?.cancel()
+ closeObserver()
+ super.onDestroy()
+ }
+
+ private fun persistPermission(uri: Uri) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ try {
+ contentResolver.takePersistableUriPermission(uri, flags)
+ } catch (_: SecurityException) {
+ appendSystemLine(getString(R.string.observer_permission_not_persisted))
+ }
+ }
+
+ private fun openDirectory(uri: Uri, startWatching: Boolean = true) {
+ val generation = ++openingGeneration
+ openingJob?.cancel()
+ demoJob?.cancel()
+ closeObserver()
+
+ directoryUri = uri
+ directory = null
+ openingDirectory = true
+ shouldObserve = startWatching
+ updateControls()
+
+ openingJob = lifecycleScope.launch {
+ try {
+ val opened = withContext(Dispatchers.IO) {
+ DocumentFileCompat.fromTreeUri(this@ObserverActivity, uri)
+ }
+ if (opened == null || !opened.isDirectory()) {
+ throw IOException("The selected document is not an accessible directory")
+ }
+ if (generation != openingGeneration) return@launch
+
+ directory = opened
+ if (startWatching) startObserver()
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (exception: Exception) {
+ if (generation != openingGeneration) return@launch
+ shouldObserve = false
+ appendSystemLine(
+ getString(
+ R.string.observer_open_failed,
+ exception.message ?: exception.javaClass.simpleName
+ )
+ )
+ } finally {
+ if (generation == openingGeneration) {
+ openingDirectory = false
+ openingJob = null
+ if (!isDestroyed) updateControls()
+ }
+ }
+ }
+ }
+
+ private fun startObserver() {
+ val observedDirectory = directory ?: return
+ closeObserver()
+ shouldObserve = true
+ val generation = observerGeneration
+
+ try {
+ val startedObserver = observedDirectory.observe { event, document ->
+ runOnUiThread {
+ if (
+ !isDestroyed &&
+ generation == observerGeneration
+ ) {
+ appendEvent(event, document)
+ }
+ }
+ }
+ observer = startedObserver
+ startedObserver.startWatching(
+ onError = { exception ->
+ runOnUiThread {
+ if (
+ !isDestroyed &&
+ generation == observerGeneration &&
+ observer === startedObserver
+ ) {
+ observer = null
+ observerReady = false
+ shouldObserve = false
+ observerGeneration++
+ appendSystemLine(
+ getString(
+ R.string.observer_failed,
+ exception.message ?: exception.javaClass.simpleName
+ )
+ )
+ updateControls()
+ }
+ }
+ },
+ onReady = {
+ runOnUiThread {
+ if (
+ !isDestroyed &&
+ generation == observerGeneration &&
+ observer === startedObserver
+ ) {
+ observerReady = true
+ appendSystemLine(getString(R.string.observer_started))
+ updateControls()
+ }
+ }
+ },
+ )
+ } catch (exception: Exception) {
+ closeObserver()
+ shouldObserve = false
+ appendSystemLine(
+ getString(
+ R.string.observer_failed,
+ exception.message ?: exception.javaClass.simpleName
+ )
+ )
+ }
+ updateControls()
+ }
+
+ private fun stopObserver() {
+ if (observer == null) return
+ shouldObserve = false
+ closeObserver()
+ appendSystemLine(getString(R.string.observer_stopped))
+ updateControls()
+ }
+
+ private fun closeObserver() {
+ observerGeneration++
+ observerReady = false
+ val stopped = observer
+ observer = null
+ stopped?.close()
+ }
+
+ private fun runDemo() {
+ val targetDirectory = directory ?: return
+ if (demoRunning) return
+
+ demoJob = lifecycleScope.launch {
+ var demoDocument: DocumentFileCompat? = null
+ var demoDeleted = false
+ demoRunning = true
+ updateControls()
+ appendSystemLine(getString(R.string.observer_demo_started))
+
+ try {
+ val suffix = System.currentTimeMillis()
+ val originalName = "dfc_observer_$suffix.txt"
+ val renamedName = "dfc_observer_${suffix}_renamed.txt"
+
+ val document = withContext(Dispatchers.IO) {
+ targetDirectory.createFile("text/plain", originalName).also {
+ demoDocument = it
+ }
+ } ?: throw IOException("The provider did not create the demo file")
+ appendSystemLine(getString(R.string.observer_demo_created, originalName))
+
+ delay(DEMO_STEP_DELAY_MS.milliseconds)
+ withContext(Dispatchers.IO) {
+ contentResolver.openOutputStream(document.uri, "wt")?.use { stream ->
+ stream.write("Modified by the FileCompat observer demo\n".toByteArray())
+ } ?: throw IOException("The provider did not open the demo file")
+ }
+ appendSystemLine(getString(R.string.observer_demo_modified, originalName))
+
+ delay(DEMO_STEP_DELAY_MS.milliseconds)
+ val renamed = withContext(Dispatchers.IO) {
+ document.renameTo(renamedName)
+ }
+ appendSystemLine(
+ if (renamed) getString(R.string.observer_demo_renamed, renamedName)
+ else getString(R.string.observer_demo_rename_unsupported)
+ )
+
+ delay(DEMO_STEP_DELAY_MS.milliseconds)
+ val deleted = withContext(Dispatchers.IO) { document.delete() }
+ if (!deleted) throw IOException("The provider did not delete the demo file")
+ demoDeleted = true
+ appendSystemLine(
+ getString(
+ R.string.observer_demo_deleted,
+ if (renamed) renamedName else originalName
+ )
+ )
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (exception: Exception) {
+ appendSystemLine(
+ getString(
+ R.string.observer_demo_failed,
+ exception.message ?: exception.javaClass.simpleName
+ )
+ )
+ } finally {
+ val leftover = demoDocument
+ if (leftover != null && !demoDeleted) {
+ withContext(NonCancellable + Dispatchers.IO) {
+ runCatching { leftover.delete() }
+ }
+ }
+ demoRunning = false
+ demoJob = null
+ if (!isDestroyed) updateControls()
+ }
+ }
+ }
+
+ private fun appendEvent(event: Int, document: DocumentFileCompat) {
+ appendLine(
+ getString(
+ R.string.observer_event_entry,
+ timestamp(),
+ eventName(event),
+ document.name.ifEmpty { document.uri.lastPathSegment.orEmpty() }
+ )
+ )
+ }
+
+ private fun appendSystemLine(message: String) {
+ appendLine(getString(R.string.observer_system_entry, timestamp(), message))
+ }
+
+ private fun appendLine(line: String) {
+ while (logLines.size >= MAX_LOG_LINES) logLines.removeFirst()
+ logLines.addLast(line)
+ renderLog()
+ scroll.post { scroll.fullScroll(ScrollView.FOCUS_DOWN) }
+ }
+
+ private fun renderLog() {
+ eventLog.text = if (logLines.isEmpty()) {
+ getString(R.string.observer_event_log_empty)
+ } else {
+ logLines.joinToString(separator = "\n")
+ }
+ }
+
+ private fun applyEdgeToEdgeInsets() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return
+
+ ViewCompat.setOnApplyWindowInsetsListener(scroll) { view, windowInsets ->
+ val insets = windowInsets.getInsets(
+ WindowInsetsCompat.Type.systemBars() or
+ WindowInsetsCompat.Type.displayCutout()
+ )
+ view.updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom)
+ windowInsets
+ }
+ ViewCompat.requestApplyInsets(scroll)
+ }
+
+ private fun updateControls() {
+ status.text = when {
+ openingDirectory -> getString(R.string.observer_status_opening)
+ observer != null && !observerReady -> getString(R.string.observer_status_starting)
+ observerReady -> getString(R.string.observer_status_observing)
+ directory != null -> getString(R.string.observer_status_stopped)
+ else -> getString(R.string.observer_status_idle)
+ }
+ selectedDirectory.text = directoryUri?.toString()
+ ?: getString(R.string.observer_no_directory)
+
+ selectButton.isEnabled = !openingDirectory && !demoRunning
+ toggleButton.isEnabled = directory != null && !openingDirectory && !demoRunning
+ toggleButton.text = getString(
+ if (observer == null) R.string.observer_start else R.string.observer_stop
+ )
+ demoButton.isEnabled = observerReady && !openingDirectory && !demoRunning
+ clearButton.isEnabled = logLines.isNotEmpty()
+ progress.isVisible = openingDirectory || (observer != null && !observerReady) || demoRunning
+ }
+
+ private fun timestamp(): String = timestampFormatter.format(Date())
+
+ private fun eventName(event: Int): String = when (event) {
+ DirectoryObserver.CREATE -> "CREATE"
+ DirectoryObserver.DELETE -> "DELETE"
+ DirectoryObserver.MODIFY -> "MODIFY"
+ DirectoryObserver.MOVED_FROM -> "MOVED_FROM"
+ DirectoryObserver.MOVED_TO -> "MOVED_TO"
+ else -> "0x${event.toString(16)}"
+ }
+
+ private companion object {
+ const val STATE_DIRECTORY_URI = "observer_directory_uri"
+ const val STATE_SHOULD_OBSERVE = "observer_should_observe"
+ const val MAX_LOG_LINES = 200
+ const val DEMO_STEP_DELAY_MS = 1_200L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 7a537b1..9fa6788 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -43,4 +43,13 @@
android:paddingVertical="12dp"
android:text="@string/test_custom_projections" />
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_observer.xml b/app/src/main/res/layout/activity_observer.xml
new file mode 100644
index 0000000..d13e702
--- /dev/null
+++ b/app/src/main/res/layout/activity_observer.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 613a2ac..dcb0847 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,4 +6,37 @@
Add Test Files
Select number of files to create
Creating test files…
+ Observe a directory
+ Directory observer
+ Select a Storage Access Framework directory and keep this screen open. Changes to direct children appear below without polling.
+ For the real provider test, switch to Files or use ADB to create, edit, rename, and delete items in the selected directory. You can also run the in-app demo.
+ Status
+ No directory selected
+ Opening directory…
+ Starting observation…
+ Observing direct children
+ Observation stopped
+ Selected directory
+ None
+ Select directory
+ Start
+ Stop
+ Run demo
+ Clear log
+ Event log
+ No events yet
+ The provider did not grant a persistable permission; this session can still continue.
+ Could not open this directory: %1$s
+ Observation failed: %1$s
+ Observation started.
+ Observation stopped.
+ Demo started. Providers may coalesce rapid changes.
+ Demo action: created %1$s
+ Demo action: modified %1$s
+ Demo action: renamed to %1$s
+ Demo action: rename was not supported by this provider.
+ Demo action: deleted %1$s
+ Demo failed: %1$s
+ %1$s %2$s %3$s
+ %1$s • %2$s
\ No newline at end of file
diff --git a/dfc/build.gradle b/dfc/build.gradle
index 6aca964..522fec4 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 {
@@ -24,4 +25,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+}
+
+dependencies {
+ compileOnly "androidx.annotation:annotation:1.10.0"
+ androidTestImplementation "junit:junit:4.13.2"
+ androidTestImplementation "androidx.test:runner:1.7.0"
+ androidTestImplementation "androidx.test.ext:junit:1.3.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..2c8e762
--- /dev/null
+++ b/dfc/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/DirectoryObserveTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/DirectoryObserveTest.kt
new file mode 100644
index 0000000..23a3fcc
--- /dev/null
+++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/DirectoryObserveTest.kt
@@ -0,0 +1,457 @@
+package com.lazygeniouz.dfc.observer
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.net.Uri
+import android.os.SystemClock
+import android.provider.DocumentsContract
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.lazygeniouz.dfc.file.DocumentFileCompat
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * End-to-end observer tests against [TestDocumentsProvider]: events are driven purely by
+ * provider notifications (no polling). The backing directory is mutated with the [File] api,
+ * mimicking external changes, followed by a `notifyChange` like a real provider would fire.
+ */
+@RunWith(AndroidJUnit4::class)
+class DirectoryObserveTest {
+
+ private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val backingDir = File(context.filesDir, TestDocumentsProvider.ROOT_ID)
+ private val treeUri: Uri = DocumentsContract.buildTreeDocumentUri(
+ TestDocumentsProvider.AUTHORITY, TestDocumentsProvider.ROOT_ID
+ )
+
+ private val events = LinkedBlockingQueue>()
+ private var observer: DirectoryObserver? = null
+ private var openedCursorBaseline = 0
+ private var closedCursorBaseline = 0
+
+ private lateinit var directory: DocumentFileCompat
+
+ @Before
+ fun setUp() {
+ TestDocumentsProvider.resetTestControls()
+ openedCursorBaseline = TestDocumentsProvider.openChildCursors.get()
+ closedCursorBaseline = TestDocumentsProvider.closedChildCursors.get()
+ backingDir.deleteRecursively()
+ backingDir.mkdirs()
+ directory = DocumentFileCompat.fromTreeUri(context, treeUri)
+ ?: fail("Could not build the observed directory").let { throw AssertionError() }
+ }
+
+ @After
+ fun tearDown() {
+ TestDocumentsProvider.resetTestControls()
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ backingDir.deleteRecursively()
+ }
+
+ // region helpers
+
+ private fun observe(
+ @DirectoryEventMask mask: Int = DirectoryObserver.ALL_EVENTS,
+ ): DirectoryObserver {
+ return directory.observe(mask) { event, document ->
+ events.add(event to document.name)
+ }.also { observer = it }
+ }
+
+ private fun createFile(name: String, content: String = "content"): File =
+ File(backingDir, name).apply { writeText(content) }
+
+ private fun notifyChildren() {
+ context.contentResolver.notifyChange(
+ TestDocumentsProvider.childrenUriOf(TestDocumentsProvider.ROOT_ID), null
+ )
+ }
+
+ private fun awaitEvent(timeoutMs: Long = 5000): Pair? =
+ events.poll(timeoutMs, TimeUnit.MILLISECONDS)
+
+ private fun awaitEventFor(name: String, event: Int? = null, timeoutMs: Long = 5000): Pair {
+ val received = events.poll(timeoutMs, TimeUnit.MILLISECONDS)
+ ?: throw AssertionError("Timed out waiting for an event for $name")
+ if (received.second != name || (event != null && received.first != event)) {
+ throw AssertionError("Expected ${event ?: "any"}/$name, received $received")
+ }
+ return received
+ }
+
+ private fun startAndAwaitWatching(started: DirectoryObserver) {
+ val completed = CountDownLatch(1)
+ val failure = AtomicReference()
+ started.startWatching(
+ onError = {
+ failure.set(it)
+ completed.countDown()
+ },
+ onReady = completed::countDown,
+ )
+ assertTrue("Observer did not finish starting", completed.await(5, TimeUnit.SECONDS))
+ failure.get()?.let {
+ throw AssertionError("Observer failed to start", it)
+ }
+ }
+
+ private fun observerThreadAlive(): Boolean =
+ Thread.getAllStackTraces().keys.any { it.name == "dfc-observer" && it.isAlive }
+
+ private fun awaitObserverThreadGone(timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (observerThreadAlive()) {
+ if (SystemClock.elapsedRealtime() >= deadline) fail("Observer worker thread was not released")
+ Thread.sleep(25)
+ }
+ }
+
+ private fun awaitAllChildCursorsClosed(timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (true) {
+ val opened = TestDocumentsProvider.openChildCursors.get() - openedCursorBaseline
+ val closed = TestDocumentsProvider.closedChildCursors.get() - closedCursorBaseline
+ if (opened == closed) return
+ if (SystemClock.elapsedRealtime() >= deadline) {
+ fail("Leaked child cursors: opened $opened, closed $closed")
+ }
+ Thread.sleep(25)
+ }
+ }
+
+ private fun awaitBlocked(thread: Thread, timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (thread.state != Thread.State.BLOCKED) {
+ if (SystemClock.elapsedRealtime() >= deadline) {
+ fail("Thread did not reach callback barrier")
+ }
+ Thread.sleep(10)
+ }
+ }
+
+ // endregion
+
+ @Test
+ fun existingChildren_emitNoEventsOnStart() {
+ createFile("a.txt")
+ createFile("b.txt")
+
+ startAndAwaitWatching(observe())
+ assertNull("Existing children emitted events", events.poll(300, TimeUnit.MILLISECONDS))
+
+ createFile("c.txt")
+ notifyChildren()
+
+ // The very first event after activation must be the new file, not a baseline replay.
+ assertEquals(DirectoryObserver.CREATE to "c.txt", awaitEvent())
+ }
+
+ @Test
+ fun externalDelete_emitsDelete() {
+ val victim = createFile("victim.txt")
+
+ startAndAwaitWatching(observe())
+
+ victim.delete()
+ notifyChildren()
+
+ awaitEventFor("victim.txt", DirectoryObserver.DELETE)
+ }
+
+ @Test
+ fun externalModify_emitsModify() {
+ val target = createFile("mod.txt", "12345")
+
+ startAndAwaitWatching(observe())
+
+ target.writeText("123456789") // size change: independent of mtime resolution
+ notifyChildren()
+
+ awaitEventFor("mod.txt", DirectoryObserver.MODIFY)
+ }
+
+ @Test
+ fun pathIdRename_emitsDeleteThenCreate() {
+ // Path based ids (like AOSP's local provider): a rename changes the id,
+ // which must surface as DELETE + CREATE, never a guessed move.
+ val target = createFile("old-name.txt")
+
+ startAndAwaitWatching(observe())
+
+ target.renameTo(File(backingDir, "new-name.txt"))
+ notifyChildren()
+
+ assertEquals(DirectoryObserver.DELETE to "old-name.txt", awaitEvent())
+ assertEquals(DirectoryObserver.CREATE to "new-name.txt", awaitEvent())
+ }
+
+ @Test
+ fun callbackRename_cannotMutateTheInternalSnapshotDocument() {
+ val callbackEvents = LinkedBlockingQueue>()
+ val renameFinished = CountDownLatch(1)
+ val renameSucceeded = AtomicReference()
+ val callbackObserver = directory.observe { event, document ->
+ callbackEvents.add(
+ Triple(event, document.name, DocumentsContract.getDocumentId(document.uri))
+ )
+ if (event == DirectoryObserver.CREATE && document.name == "callback.txt") {
+ renameSucceeded.set(document.renameTo("renamed.txt"))
+ renameFinished.countDown()
+ }
+ }.also { observer = it }
+ startAndAwaitWatching(callbackObserver)
+
+ createFile("callback.txt")
+ notifyChildren()
+
+ assertTrue("Callback rename did not finish", renameFinished.await(5, TimeUnit.SECONDS))
+ assertEquals(true, renameSucceeded.get())
+ val first = callbackEvents.poll(5, TimeUnit.SECONDS)
+ ?: throw AssertionError("Missing original CREATE event")
+ val second = callbackEvents.poll(5, TimeUnit.SECONDS)
+ ?: throw AssertionError("Missing DELETE event after rename")
+ val third = callbackEvents.poll(5, TimeUnit.SECONDS)
+ ?: throw AssertionError("Missing CREATE event after rename")
+
+ assertEquals(
+ Triple(DirectoryObserver.CREATE, "callback.txt", "${TestDocumentsProvider.ROOT_ID}/callback.txt"),
+ first,
+ )
+ assertEquals(
+ Triple(DirectoryObserver.DELETE, "callback.txt", "${TestDocumentsProvider.ROOT_ID}/callback.txt"),
+ second,
+ )
+ assertEquals(
+ Triple(DirectoryObserver.CREATE, "renamed.txt", "${TestDocumentsProvider.ROOT_ID}/renamed.txt"),
+ third,
+ )
+ }
+
+ @Test
+ fun maskFiltering_suppressesUnrequestedEvents() {
+ val victim = createFile("victim.txt")
+ val deleteOnly = observe(DirectoryObserver.DELETE)
+ startAndAwaitWatching(deleteOnly)
+
+ createFile("noise.txt") // CREATE: must be filtered out
+ victim.delete()
+ notifyChildren()
+
+ assertEquals(DirectoryObserver.DELETE to "victim.txt", awaitEvent())
+ }
+
+ @Test
+ fun notificationBurst_coalesces_withoutDuplicateEvents() {
+ startAndAwaitWatching(observe())
+
+ createFile("b1.txt")
+ createFile("b2.txt")
+ createFile("b3.txt")
+ repeat(5) { notifyChildren() }
+
+ val received = mutableSetOf()
+ repeat(3) {
+ val (event, name) = awaitEvent() ?: fail("Missing burst event").let { throw AssertionError() }
+ assertEquals(DirectoryObserver.CREATE, event)
+ received.add(name)
+ }
+ assertEquals(setOf("b1.txt", "b2.txt", "b3.txt"), received)
+
+ // Sentinel proves the 5 notifications produced no duplicate events.
+ createFile("sentinel.txt")
+ notifyChildren()
+ assertEquals(DirectoryObserver.CREATE to "sentinel.txt", awaitEvent())
+ }
+
+ @Test
+ fun stopWatching_stopsEvents() {
+ startAndAwaitWatching(observe())
+
+ observer?.stopWatching()
+
+ createFile("late.txt")
+ notifyChildren()
+
+ assertNull(events.poll(1500, TimeUnit.MILLISECONDS))
+ }
+
+ @Test
+ fun stopWatching_waitsForAdmittedCallback_andBlocksLaterCallbacks() {
+ val callbackEntered = CountDownLatch(1)
+ val releaseCallback = CountDownLatch(1)
+ val stopReturned = CountDownLatch(1)
+ val callbacks = AtomicInteger(0)
+ val blocking = directory.observe { _, _ ->
+ callbacks.incrementAndGet()
+ callbackEntered.countDown()
+ releaseCallback.await(5, TimeUnit.SECONDS)
+ }.also { observer = it }
+ startAndAwaitWatching(blocking)
+
+ createFile("blocking.txt")
+ notifyChildren()
+ assertTrue("Listener was not admitted", callbackEntered.await(5, TimeUnit.SECONDS))
+
+ val stopper = Thread {
+ blocking.stopWatching()
+ stopReturned.countDown()
+ }.apply { start() }
+ assertFalse("Stop returned while a callback was running", stopReturned.await(200, TimeUnit.MILLISECONDS))
+
+ releaseCallback.countDown()
+ assertTrue("Stop did not return after the callback", stopReturned.await(5, TimeUnit.SECONDS))
+ stopper.join(5000)
+
+ createFile("after-stop.txt")
+ notifyChildren()
+ Thread.sleep(300)
+ assertEquals(1, callbacks.get())
+ }
+
+ @Test
+ fun concurrentStops_bothWaitForAdmittedCallback() {
+ val callbackEntered = CountDownLatch(1)
+ val releaseCallback = CountDownLatch(1)
+ val firstReturned = CountDownLatch(1)
+ val secondReturned = CountDownLatch(1)
+ val blocking = directory.observe { _, _ ->
+ callbackEntered.countDown()
+ releaseCallback.await(5, TimeUnit.SECONDS)
+ }.also { observer = it }
+ startAndAwaitWatching(blocking)
+
+ createFile("two-stoppers.txt")
+ notifyChildren()
+ assertTrue("Listener was not admitted", callbackEntered.await(5, TimeUnit.SECONDS))
+
+ val first = Thread {
+ blocking.stopWatching()
+ firstReturned.countDown()
+ }.apply { start() }
+
+ var second: Thread? = null
+ try {
+ awaitBlocked(first)
+ second = Thread {
+ blocking.stopWatching()
+ secondReturned.countDown()
+ }.apply { start() }
+
+ assertFalse(
+ "Concurrent stop returned while the callback was running",
+ secondReturned.await(200, TimeUnit.MILLISECONDS),
+ )
+ } finally {
+ releaseCallback.countDown()
+ }
+
+ assertTrue("First stop did not return", firstReturned.await(5, TimeUnit.SECONDS))
+ assertTrue("Second stop did not return", secondReturned.await(5, TimeUnit.SECONDS))
+ first.join(5000)
+ second?.join(5000)
+ }
+
+ @Test
+ fun restartAfterStop_deliversEventsAgain() {
+ startAndAwaitWatching(observe())
+ observer?.stopWatching()
+
+ startAndAwaitWatching(observer!!)
+
+ createFile("again.txt")
+ notifyChildren()
+
+ awaitEventFor("again.txt", DirectoryObserver.CREATE)
+ }
+
+ @Test
+ fun startAndStop_areIdempotent() {
+ val doubled = observe()
+ val ready = CountDownLatch(1)
+ val duplicateCallback = AtomicBoolean(false)
+ doubled.startWatching(onReady = ready::countDown)
+ doubled.startWatching(onReady = { duplicateCallback.set(true) })
+ assertTrue("Observer did not become ready", ready.await(5, TimeUnit.SECONDS))
+ assertTrue("Duplicate start callback ran", !duplicateCallback.get())
+
+ doubled.stopWatching()
+ doubled.stopWatching() // no-op
+
+ createFile("late.txt")
+ notifyChildren()
+ assertNull(events.poll(1500, TimeUnit.MILLISECONDS))
+ }
+
+ @Test
+ fun stopFromInsideListener_doesNotDeadlock_orEmitFurther() {
+ val callbacks = AtomicInteger(0)
+ val firstEvent = CountDownLatch(1)
+ lateinit var selfStopping: DirectoryObserver
+ selfStopping = directory.observe { _, _ ->
+ callbacks.incrementAndGet()
+ selfStopping.stopWatching() // must not deadlock
+ firstEvent.countDown()
+ }
+ observer = selfStopping
+ startAndAwaitWatching(selfStopping)
+ createFile("trigger.txt")
+ notifyChildren()
+ assertTrue("Listener was never invoked", firstEvent.await(5, TimeUnit.SECONDS))
+
+ val countAtStop = callbacks.get()
+ createFile("after-stop.txt")
+ notifyChildren()
+ Thread.sleep(1500)
+
+ assertEquals(countAtStop, callbacks.get())
+ }
+
+ @Test
+ fun observe_onNonDirectory_throws() {
+ val plain = createFile("plain.txt")
+
+ // A file child of the observed tree, exactly as listFiles() hands it out.
+ val single = directory.listFiles().first { it.name == "plain.txt" }
+ assertThrows(UnsupportedOperationException::class.java) {
+ single.observe { _, _ -> }
+ }
+
+ val raw = DocumentFileCompat.fromFile(context, plain)
+ assertThrows(UnsupportedOperationException::class.java) {
+ raw.observe { _, _ -> }
+ }
+ }
+
+ @Test
+ @SuppressLint("WrongConstant")
+ fun observe_withInvalidEventMask_throws() {
+ assertThrows(IllegalArgumentException::class.java) {
+ directory.observe(0) { _, _ -> }
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ directory.observe(0x20 /* OPEN */) { _, _ -> }
+ }
+ assertThrows(IllegalArgumentException::class.java) {
+ directory.observe(DirectoryObserver.CREATE or 0x20 /* OPEN */) { _, _ -> }
+ }
+ }
+}
\ No newline at end of file
diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/ObserverResilienceTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/ObserverResilienceTest.kt
new file mode 100644
index 0000000..a5472c7
--- /dev/null
+++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/ObserverResilienceTest.kt
@@ -0,0 +1,722 @@
+package com.lazygeniouz.dfc.observer
+
+import android.content.Context
+import android.net.Uri
+import android.os.SystemClock
+import android.provider.DocumentsContract
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.lazygeniouz.dfc.file.DocumentFileCompat
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+/** Failure, coalescing, cancellation, and resource-ownership regression tests. */
+@RunWith(AndroidJUnit4::class)
+class ObserverResilienceTest {
+
+ private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val backingDir = File(context.filesDir, TestDocumentsProvider.ROOT_ID)
+ private val treeUri: Uri = DocumentsContract.buildTreeDocumentUri(
+ TestDocumentsProvider.AUTHORITY, TestDocumentsProvider.ROOT_ID
+ )
+
+ private val events = LinkedBlockingQueue>()
+ private val errors = LinkedBlockingQueue()
+ private var observer: DirectoryObserver? = null
+ private var openedCursorBaseline = 0
+ private var closedCursorBaseline = 0
+
+ private lateinit var directory: DocumentFileCompat
+
+ @Before
+ fun setUp() {
+ TestDocumentsProvider.resetTestControls()
+ openedCursorBaseline = TestDocumentsProvider.openChildCursors.get()
+ closedCursorBaseline = TestDocumentsProvider.closedChildCursors.get()
+ backingDir.deleteRecursively()
+ backingDir.mkdirs()
+ directory = DocumentFileCompat.fromTreeUri(context, treeUri)
+ ?: fail("Could not build the observed directory").let { throw AssertionError() }
+ }
+
+ @After
+ fun tearDown() {
+ TestDocumentsProvider.resetTestControls()
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ backingDir.deleteRecursively()
+ }
+
+ private fun observe(): DirectoryObserver {
+ return directory.observe { event, document ->
+ events.add(event to document.name)
+ }.also { observer = it }
+ }
+
+ private fun createFile(name: String, content: String = "content"): File =
+ File(backingDir, name).apply { writeText(content) }
+
+ private fun notifyChildren() {
+ context.contentResolver.notifyChange(
+ TestDocumentsProvider.childrenUriOf(TestDocumentsProvider.ROOT_ID), null
+ )
+ }
+
+ private fun requireEvent(timeoutMs: Long = 5000): Pair =
+ events.poll(timeoutMs, TimeUnit.MILLISECONDS)
+ ?: fail("Timed out waiting for an event").let { throw AssertionError() }
+
+ private fun startAndAwaitWatching(started: DirectoryObserver) {
+ val completed = CountDownLatch(1)
+ val failure = AtomicReference()
+ started.startWatching(
+ onError = {
+ errors.add(it)
+ failure.set(it)
+ completed.countDown()
+ },
+ onReady = completed::countDown,
+ )
+ assertTrue("Observer did not finish starting", completed.await(5, TimeUnit.SECONDS))
+ failure.get()?.let { throw AssertionError("Observer failed to start", it) }
+ }
+
+ private fun startAndAwaitFailure(started: DirectoryObserver): Throwable {
+ val completed = CountDownLatch(1)
+ val failure = AtomicReference()
+ val becameReady = AtomicReference(false)
+ started.startWatching(
+ onError = {
+ failure.set(it)
+ completed.countDown()
+ },
+ onReady = {
+ becameReady.set(true)
+ completed.countDown()
+ },
+ )
+ assertTrue("Observer did not report startup completion", completed.await(5, TimeUnit.SECONDS))
+ assertFalse("Observer became ready despite terminal failure", becameReady.get())
+ return failure.get() ?: throw AssertionError("Observer supplied no failure")
+ }
+
+ private fun observerThreadAlive(): Boolean =
+ Thread.getAllStackTraces().keys.any { it.name == "dfc-observer" && it.isAlive }
+
+ private fun awaitObserverThreadGone(timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (observerThreadAlive()) {
+ if (SystemClock.elapsedRealtime() >= deadline) {
+ fail("Observer worker thread was not released")
+ }
+ Thread.sleep(25)
+ }
+ }
+
+ private fun awaitQueryCountAbove(baseline: Int, timeoutMs: Long = 5000) {
+ awaitCounterAbove(TestDocumentsProvider.childQueryCount, baseline, timeoutMs)
+ }
+
+ private fun awaitCounterAbove(counter: AtomicInteger, baseline: Int, timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (counter.get() <= baseline) {
+ if (SystemClock.elapsedRealtime() >= deadline) fail("Expected counter did not advance")
+ Thread.sleep(10)
+ }
+ }
+
+ private fun awaitQueryDelta(baseline: Int, expected: Int, timeoutMs: Long = 5000) {
+ awaitCounterDelta(TestDocumentsProvider.childQueryCount, baseline, expected, timeoutMs)
+ }
+
+ private fun awaitCounterDelta(
+ counter: AtomicInteger,
+ baseline: Int,
+ expected: Int,
+ timeoutMs: Long = 5000,
+ ) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (counter.get() - baseline < expected) {
+ if (SystemClock.elapsedRealtime() >= deadline) fail("Expected $expected counter updates")
+ Thread.sleep(10)
+ }
+ }
+
+ private fun awaitAllChildCursorsClosed(timeoutMs: Long = 5000) {
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
+ while (true) {
+ val opened = TestDocumentsProvider.openChildCursors.get() - openedCursorBaseline
+ val closed = TestDocumentsProvider.closedChildCursors.get() - closedCursorBaseline
+ if (opened == closed) return
+ if (SystemClock.elapsedRealtime() >= deadline) {
+ fail("Leaked child cursors: opened $opened, closed $closed")
+ }
+ Thread.sleep(25)
+ }
+ }
+
+ @Test
+ fun permissionRevocation_isTerminal_releasesSessionAndAllowsRestart() {
+ startAndAwaitWatching(observe())
+
+ TestDocumentsProvider.revokePermissions = true
+ createFile("denied.txt")
+ notifyChildren()
+
+ assertTrue(errors.poll(5, TimeUnit.SECONDS) is SecurityException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+
+ val countAfterTermination = TestDocumentsProvider.childQueryCount.get()
+ notifyChildren()
+ Thread.sleep(250)
+ assertEquals(countAfterTermination, TestDocumentsProvider.childQueryCount.get())
+
+ TestDocumentsProvider.revokePermissions = false
+ startAndAwaitWatching(observer!!)
+ createFile("recovered.txt")
+ notifyChildren()
+ assertEquals(DirectoryObserver.CREATE to "recovered.txt", requireEvent())
+ }
+
+ @Test
+ fun startupQueryFailure_reportsErrorAndAllowsRestart() {
+ TestDocumentsProvider.failChildQueries = true
+ val failed = observe()
+
+ assertTrue(startAndAwaitFailure(failed) is IllegalStateException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+
+ TestDocumentsProvider.failChildQueries = false
+ startAndAwaitWatching(failed)
+ }
+
+ @Test
+ fun startupBaselineFailure_neverReportsReady() {
+ TestDocumentsProvider.failChildQueryAt = TestDocumentsProvider.childQueryCount.get() + 2
+ val started = observe()
+ val completed = CountDownLatch(1)
+ val becameReady = AtomicBoolean(false)
+ val failure = AtomicReference()
+ started.startWatching(
+ onError = {
+ failure.set(it)
+ completed.countDown()
+ },
+ onReady = {
+ becameReady.set(true)
+ completed.countDown()
+ },
+ )
+
+ assertTrue("Startup did not terminate", completed.await(5, TimeUnit.SECONDS))
+ assertFalse("A failed baseline was reported ready", becameReady.get())
+ assertTrue(failure.get() is IllegalStateException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ }
+
+ @Test
+ fun startupScansChildrenTwice_withOneFullSnapshot() {
+ repeat(3) { createFile("existing-$it.txt") }
+ val notificationQueries = TestDocumentsProvider.notificationChildQueries.get()
+ val snapshotQueries = TestDocumentsProvider.snapshotChildQueries.get()
+ val notificationRows = TestDocumentsProvider.notificationChildRows.get()
+ val snapshotRows = TestDocumentsProvider.snapshotChildRows.get()
+
+ startAndAwaitWatching(observe())
+
+ assertEquals(1, TestDocumentsProvider.notificationChildQueries.get() - notificationQueries)
+ assertEquals(1, TestDocumentsProvider.snapshotChildQueries.get() - snapshotQueries)
+ assertEquals(3, TestDocumentsProvider.notificationChildRows.get() - notificationRows)
+ assertEquals(3, TestDocumentsProvider.snapshotChildRows.get() - snapshotRows)
+ assertTrue(events.isEmpty())
+ }
+
+ @Test
+ fun mutationAfterStartupSnapshot_isDeliveredAfterReady() {
+ val snapshotCaptured = CountDownLatch(1)
+ val returnGate = CountDownLatch(1)
+ TestDocumentsProvider.childSnapshotCaptured = snapshotCaptured
+ TestDocumentsProvider.childQueryReturnGate = returnGate
+
+ val callbacks = LinkedBlockingQueue()
+ val started = directory.observe { _, document ->
+ callbacks.add("event:${document.name}")
+ }.also { observer = it }
+ started.startWatching(onError = errors::add) { callbacks.add("ready") }
+
+ assertTrue("Startup snapshot was not captured", snapshotCaptured.await(5, TimeUnit.SECONDS))
+ createFile("during-startup.txt")
+ notifyChildren()
+
+ returnGate.countDown()
+ TestDocumentsProvider.childQueryReturnGate = null
+ TestDocumentsProvider.childSnapshotCaptured = null
+
+ assertEquals("ready", callbacks.poll(5, TimeUnit.SECONDS))
+ assertEquals("event:during-startup.txt", callbacks.poll(5, TimeUnit.SECONDS))
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun loadingStartup_waitsForACompleteBaseline() {
+ createFile("cached.txt")
+ createFile("remote.txt")
+ TestDocumentsProvider.returnLoadingChildren = true
+ TestDocumentsProvider.loadingChildLimit = 1
+
+ val started = observe()
+ val ready = CountDownLatch(1)
+ val queryBaseline = TestDocumentsProvider.childQueryCount.get()
+ started.startWatching(onError = errors::add, onReady = ready::countDown)
+
+ awaitQueryCountAbove(queryBaseline)
+ assertFalse("Observer became ready from a partial cursor", ready.await(250, TimeUnit.MILLISECONDS))
+ assertTrue(events.isEmpty())
+
+ TestDocumentsProvider.returnLoadingChildren = false
+ notifyChildren()
+
+ assertTrue("Observer did not become ready after loading completed", ready.await(5, TimeUnit.SECONDS))
+ assertTrue(events.isEmpty())
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun loadingCompletionDuringBlockedBaseline_stillBecomesReady() {
+ createFile("cached.txt")
+ createFile("remote.txt")
+ TestDocumentsProvider.returnLoadingChildren = true
+ TestDocumentsProvider.loadingChildLimit = 1
+
+ val snapshotCaptured = CountDownLatch(1)
+ val returnGate = CountDownLatch(1)
+ TestDocumentsProvider.childSnapshotCaptured = snapshotCaptured
+ TestDocumentsProvider.childQueryReturnGate = returnGate
+
+ val started = observe()
+ val ready = CountDownLatch(1)
+ started.startWatching(onError = errors::add, onReady = ready::countDown)
+
+ assertTrue("Partial snapshot was not captured", snapshotCaptured.await(5, TimeUnit.SECONDS))
+ TestDocumentsProvider.returnLoadingChildren = false
+ notifyChildren()
+ TestDocumentsProvider.childSnapshotCaptured = null
+ TestDocumentsProvider.childQueryReturnGate = null
+ returnGate.countDown()
+
+ assertTrue("Observer remained stuck on the partial cursor", ready.await(5, TimeUnit.SECONDS))
+ assertTrue(events.isEmpty())
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun loadingRefresh_neverTurnsOmittedRowsIntoDeletions() {
+ createFile("a.txt")
+ createFile("b.txt")
+ startAndAwaitWatching(observe())
+
+ TestDocumentsProvider.returnLoadingChildren = true
+ TestDocumentsProvider.loadingChildLimit = 1
+ val queryBaseline = TestDocumentsProvider.childQueryCount.get()
+ notifyChildren()
+ awaitQueryCountAbove(queryBaseline)
+
+ assertNull(events.poll(250, TimeUnit.MILLISECONDS))
+
+ assertTrue(File(backingDir, "b.txt").delete())
+ TestDocumentsProvider.returnLoadingChildren = false
+ notifyChildren()
+
+ assertEquals(DirectoryObserver.DELETE to "b.txt", requireEvent())
+ assertNull(events.poll(250, TimeUnit.MILLISECONDS))
+ }
+
+ @Test
+ fun readySession_retainsOnlyTheLightweightNotificationCursor() {
+ createFile("existing.txt")
+ startAndAwaitWatching(observe())
+
+ assertEquals(1, TestDocumentsProvider.activeNotificationCursors.get())
+ assertEquals(0, TestDocumentsProvider.activeSnapshotCursors.get())
+
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ assertEquals(0, TestDocumentsProvider.activeNotificationCursors.get())
+ assertEquals(0, TestDocumentsProvider.activeSnapshotCursors.get())
+ }
+
+ @Test
+ fun refreshReleasesSnapshotCursor_beforeListenerRuns() {
+ val listenerEntered = CountDownLatch(1)
+ val releaseListener = CountDownLatch(1)
+ val activeSnapshotCursors = AtomicInteger(-1)
+ val started = directory.observe { _, _ ->
+ activeSnapshotCursors.set(TestDocumentsProvider.activeSnapshotCursors.get())
+ listenerEntered.countDown()
+ releaseListener.await(5, TimeUnit.SECONDS)
+ }.also { observer = it }
+ startAndAwaitWatching(started)
+
+ createFile("callback.txt")
+ notifyChildren()
+
+ try {
+ assertTrue("Listener did not run", listenerEntered.await(5, TimeUnit.SECONDS))
+ assertEquals(0, activeSnapshotCursors.get())
+ } finally {
+ releaseListener.countDown()
+ }
+ }
+
+ @Test
+ fun registrationRevocation_reportsErrorAndReleasesSession() {
+ TestDocumentsProvider.failObserverRegistration = true
+
+ assertTrue(startAndAwaitFailure(observe()) is SecurityException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ }
+
+ @Test
+ fun transientQueryFailure_keepsWatchAliveAndReconcilesMissedChanges() {
+ startAndAwaitWatching(observe())
+
+ val failedBaseline = TestDocumentsProvider.failedChildQueries.get()
+ TestDocumentsProvider.failChildQueries = true
+ createFile("missed.txt")
+ notifyChildren()
+ awaitCounterAbove(TestDocumentsProvider.failedChildQueries, failedBaseline)
+ assertTrue(events.isEmpty())
+
+ TestDocumentsProvider.failChildQueries = false
+ createFile("caught.txt")
+ notifyChildren()
+
+ assertEquals(
+ setOf(
+ DirectoryObserver.CREATE to "missed.txt",
+ DirectoryObserver.CREATE to "caught.txt",
+ ),
+ setOf(requireEvent(), requireEvent())
+ )
+ }
+
+ @Test
+ fun oneShotRefreshFailure_retriesWithoutAnotherNotification() {
+ startAndAwaitWatching(observe())
+
+ val failedBaseline = TestDocumentsProvider.failedChildQueries.get()
+ val queryBaseline = TestDocumentsProvider.childQueryCount.get()
+ TestDocumentsProvider.failNextChildQueries.set(1)
+ createFile("retried.txt")
+ notifyChildren()
+
+ awaitCounterAbove(TestDocumentsProvider.failedChildQueries, failedBaseline)
+ assertEquals(DirectoryObserver.CREATE to "retried.txt", requireEvent())
+ assertTrue(TestDocumentsProvider.childQueryCount.get() - queryBaseline >= 2)
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun nullRefreshCursor_retryExhaustionIsTerminalAndAllowsRestart() {
+ startAndAwaitWatching(observe())
+
+ val nullBaseline = TestDocumentsProvider.nullChildQueries.get()
+ TestDocumentsProvider.returnNullChildQueries = true
+ createFile("missed-null-cursor.txt")
+ notifyChildren()
+
+ awaitCounterDelta(TestDocumentsProvider.nullChildQueries, nullBaseline, 2)
+ assertTrue(errors.poll(5, TimeUnit.SECONDS) is IOException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ assertTrue(events.isEmpty())
+
+ TestDocumentsProvider.returnNullChildQueries = false
+ startAndAwaitWatching(observer!!)
+ createFile("after-null-cursor.txt")
+ notifyChildren()
+
+ assertEquals(
+ DirectoryObserver.CREATE to "after-null-cursor.txt",
+ requireEvent(),
+ )
+ }
+
+ @Test
+ fun nullDirectoryCheckCursor_retryExhaustionIsTerminalAndAllowsRestart() {
+ startAndAwaitWatching(observe())
+
+ val nullBaseline = TestDocumentsProvider.nullDocumentQueries.get()
+ TestDocumentsProvider.returnNullDocumentQueries = true
+ notifyChildren()
+
+ awaitCounterDelta(TestDocumentsProvider.nullDocumentQueries, nullBaseline, 2)
+ assertTrue(errors.poll(5, TimeUnit.SECONDS) is IOException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+
+ TestDocumentsProvider.returnNullDocumentQueries = false
+ startAndAwaitWatching(observer!!)
+ createFile("after-null-directory-check.txt")
+ notifyChildren()
+
+ assertEquals(
+ DirectoryObserver.CREATE to "after-null-directory-check.txt",
+ requireEvent(),
+ )
+ }
+
+ @Test
+ fun watchedDirectoryDeletion_isTerminalAndAllowsRestart() {
+ createFile("existing.txt")
+ startAndAwaitWatching(observe())
+
+ assertTrue(backingDir.deleteRecursively())
+ notifyChildren()
+
+ assertTrue(errors.poll(5, TimeUnit.SECONDS) is FileNotFoundException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+
+ assertTrue(backingDir.mkdirs())
+ startAndAwaitWatching(observer!!)
+ }
+
+ @Test
+ fun notificationBurst_coalescesToExactlyTwoQueries() {
+ startAndAwaitWatching(observe())
+
+ val gate = CountDownLatch(1)
+ TestDocumentsProvider.childQueryGate = gate
+ val baseline = TestDocumentsProvider.childQueryCount.get()
+
+ createFile("b1.txt")
+ notifyChildren()
+ awaitQueryCountAbove(baseline)
+
+ createFile("b2.txt")
+ createFile("b3.txt")
+ repeat(9) { notifyChildren() }
+
+ gate.countDown()
+ TestDocumentsProvider.childQueryGate = null
+
+ assertEquals(
+ setOf(
+ DirectoryObserver.CREATE to "b1.txt",
+ DirectoryObserver.CREATE to "b2.txt",
+ DirectoryObserver.CREATE to "b3.txt",
+ ),
+ setOf(requireEvent(), requireEvent(), requireEvent())
+ )
+ awaitQueryDelta(baseline, 2)
+
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ assertEquals(2, TestDocumentsProvider.childQueryCount.get() - baseline)
+ }
+
+ @Test
+ fun mutationAfterRefreshSnapshot_triggersOneFollowUpQuery() {
+ startAndAwaitWatching(observe())
+
+ val snapshotCaptured = CountDownLatch(1)
+ val returnGate = CountDownLatch(1)
+ TestDocumentsProvider.childSnapshotCaptured = snapshotCaptured
+ TestDocumentsProvider.childQueryReturnGate = returnGate
+ val baseline = TestDocumentsProvider.childQueryCount.get()
+
+ createFile("m1.txt")
+ notifyChildren()
+ assertTrue("Refresh snapshot was not captured", snapshotCaptured.await(5, TimeUnit.SECONDS))
+
+ createFile("m2.txt")
+ notifyChildren()
+
+ returnGate.countDown()
+ TestDocumentsProvider.childQueryReturnGate = null
+ TestDocumentsProvider.childSnapshotCaptured = null
+
+ assertEquals(
+ setOf(
+ DirectoryObserver.CREATE to "m1.txt",
+ DirectoryObserver.CREATE to "m2.txt",
+ ),
+ setOf(requireEvent(), requireEvent())
+ )
+ awaitQueryDelta(baseline, 2)
+
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ assertEquals(2, TestDocumentsProvider.childQueryCount.get() - baseline)
+ }
+
+ @Test
+ fun permissionRevocationDuringCursorMaterialization_isTerminal() {
+ startAndAwaitWatching(observe())
+
+ TestDocumentsProvider.revokeDuringMaterialization = true
+ createFile("denied-materialization.txt")
+ notifyChildren()
+
+ assertTrue(errors.poll(5, TimeUnit.SECONDS) is SecurityException)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+
+ TestDocumentsProvider.revokeDuringMaterialization = false
+ startAndAwaitWatching(observer!!)
+ createFile("recovered-materialization.txt")
+ notifyChildren()
+ assertEquals(DirectoryObserver.CREATE to "recovered-materialization.txt", requireEvent())
+ }
+
+ @Test
+ fun stopDuringTerminalCallback_waitsUntilTheCallbackFinishes() {
+ val callbackEntered = CountDownLatch(1)
+ val releaseCallback = CountDownLatch(1)
+ val stopReturned = CountDownLatch(1)
+ val terminal = directory.observe { _, _ -> }.also { observer = it }
+ val ready = CountDownLatch(1)
+ terminal.startWatching(
+ onError = {
+ callbackEntered.countDown()
+ releaseCallback.await(5, TimeUnit.SECONDS)
+ },
+ onReady = ready::countDown,
+ )
+ assertTrue("Observer did not become ready", ready.await(5, TimeUnit.SECONDS))
+
+ TestDocumentsProvider.revokePermissions = true
+ notifyChildren()
+ assertTrue("Terminal callback was not admitted", callbackEntered.await(5, TimeUnit.SECONDS))
+
+ val stopper = Thread {
+ terminal.stopWatching()
+ stopReturned.countDown()
+ }.apply { start() }
+ assertFalse("Stop returned while onError was running", stopReturned.await(200, TimeUnit.MILLISECONDS))
+
+ releaseCallback.countDown()
+ assertTrue("Stop did not return after onError", stopReturned.await(5, TimeUnit.SECONDS))
+ stopper.join(5000)
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ }
+
+ @Test
+ fun stopBeforeTerminalCallback_suppressesTheCallback() {
+ startAndAwaitWatching(observe())
+
+ val closeStarted = CountDownLatch(1)
+ val closeGate = CountDownLatch(1)
+ TestDocumentsProvider.childCursorCloseStarted = closeStarted
+ TestDocumentsProvider.childCursorCloseGate = closeGate
+
+ TestDocumentsProvider.revokePermissions = true
+ notifyChildren()
+ assertTrue("Terminal cleanup did not begin", closeStarted.await(5, TimeUnit.SECONDS))
+
+ observer?.stopWatching()
+ assertTrue("onError ran before stop returned", errors.isEmpty())
+
+ closeGate.countDown()
+ TestDocumentsProvider.childCursorCloseGate = null
+ TestDocumentsProvider.childCursorCloseStarted = null
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ assertNull("onError ran after stop returned", errors.poll(250, TimeUnit.MILLISECONDS))
+ }
+
+ @Test
+ fun stopDuringBlockedQuery_returnsImmediately_thenReleasesAfterQueryReturns() {
+ startAndAwaitWatching(observe())
+
+ val gate = CountDownLatch(1)
+ TestDocumentsProvider.childQueryGate = gate
+ val countBefore = TestDocumentsProvider.childQueryCount.get()
+
+ createFile("blocked.txt")
+ notifyChildren()
+ awaitQueryCountAbove(countBefore)
+
+ val stopStarted = SystemClock.elapsedRealtime()
+ observer?.stopWatching()
+ assertTrue(SystemClock.elapsedRealtime() - stopStarted < 500)
+
+ gate.countDown()
+ TestDocumentsProvider.childQueryGate = null
+
+ assertNull(events.poll(500, TimeUnit.MILLISECONDS))
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ }
+
+ @Test
+ fun restartAfterSelfStop_doesNotWaitForBlockedTeardown() {
+ val callbackReturned = CountDownLatch(1)
+ val closeStarted = CountDownLatch(1)
+ val closeGate = CountDownLatch(1)
+ lateinit var selfStopping: DirectoryObserver
+ selfStopping = directory.observe { _, _ ->
+ TestDocumentsProvider.childCursorCloseStarted = closeStarted
+ TestDocumentsProvider.childCursorCloseGate = closeGate
+ selfStopping.stopWatching()
+ callbackReturned.countDown()
+ }.also { observer = it }
+ startAndAwaitWatching(selfStopping)
+
+ createFile("self-stop.txt")
+ notifyChildren()
+ assertTrue("Self-stop did not return", callbackReturned.await(5, TimeUnit.SECONDS))
+ assertTrue("Old teardown did not begin", closeStarted.await(5, TimeUnit.SECONDS))
+
+ val restartQueryBaseline = TestDocumentsProvider.childQueryCount.get()
+ val restarted = CountDownLatch(1)
+ try {
+ selfStopping.startWatching(onError = errors::add, onReady = restarted::countDown)
+ awaitQueryCountAbove(restartQueryBaseline)
+ assertFalse("Restart completed through blocked cleanup", restarted.await(200, TimeUnit.MILLISECONDS))
+ } finally {
+ closeGate.countDown()
+ TestDocumentsProvider.childCursorCloseGate = null
+ TestDocumentsProvider.childCursorCloseStarted = null
+ }
+
+ assertTrue("Restart did not finish", restarted.await(5, TimeUnit.SECONDS))
+ assertTrue(errors.isEmpty())
+ }
+
+ @Test
+ fun workerThread_isReleasedAfterStop() {
+ startAndAwaitWatching(observe())
+ assertTrue(observerThreadAlive())
+
+ observer?.stopWatching()
+ awaitObserverThreadGone()
+ awaitAllChildCursorsClosed()
+ }
+}
diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/TestDocumentsProvider.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/TestDocumentsProvider.kt
new file mode 100644
index 0000000..3fcfa3f
--- /dev/null
+++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/TestDocumentsProvider.kt
@@ -0,0 +1,286 @@
+package com.lazygeniouz.dfc.observer
+
+import android.database.ContentObserver
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.os.CancellationSignal
+import android.os.Bundle
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document
+import android.provider.DocumentsContract.Root
+import android.provider.DocumentsProvider
+import java.io.File
+import java.io.FileNotFoundException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Minimal SAF provider backed by `filesDir/root`, reimplementing the notification contract of
+ * AOSP's `FileSystemProvider`: child cursors carry a notification uri, tests mutate the backing
+ * directory & call `notifyChange` on it. Document ids are path based (`root/name`), so renames
+ * are id-changing — same as the real local provider.
+ */
+class TestDocumentsProvider : DocumentsProvider() {
+
+ override fun onCreate(): Boolean = true
+
+ private val baseDir: File
+ get() = File(context!!.filesDir, ROOT_ID)
+
+ private fun fileFor(documentId: String): File {
+ if (documentId == ROOT_ID) return baseDir
+ return File(baseDir, documentId.removePrefix("$ROOT_ID/"))
+ }
+
+ override fun queryRoots(projection: Array?): Cursor =
+ MatrixCursor(projection ?: arrayOf(Root.COLUMN_ROOT_ID))
+
+ override fun queryDocument(documentId: String, projection: Array?): Cursor? {
+ if (returnNullDocumentQueries) {
+ nullDocumentQueries.incrementAndGet()
+ return null
+ }
+ return MatrixCursor(resolve(projection)).also { cursor ->
+ include(cursor, documentId, fileFor(documentId))
+ }
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String,
+ projection: Array?,
+ sortOrder: String?,
+ ): Cursor? {
+ val queryNumber = childQueryCount.incrementAndGet()
+ childQueryGate?.await(10, TimeUnit.SECONDS)
+ if (revokePermissions) throw SecurityException("Permission revoked (test)")
+ if (failChildQueryAt == queryNumber || consumeNextChildQueryFailure() || failChildQueries) {
+ if (failChildQueryAt == queryNumber) failChildQueryAt = null
+ failedChildQueries.incrementAndGet()
+ throw IllegalStateException("Transient failure (test)")
+ }
+ if (returnNullChildQueries) {
+ nullChildQueries.incrementAndGet()
+ return null
+ }
+
+ val columns = resolve(projection)
+ val loading = returnLoadingChildren
+ val notificationOnly = columns.contentEquals(arrayOf(Document.COLUMN_ICON))
+ val cursor = TrackingCursor(columns, loading, notificationOnly)
+ openChildCursors.incrementAndGet()
+ if (notificationOnly) {
+ notificationChildQueries.incrementAndGet()
+ activeNotificationCursors.incrementAndGet()
+ } else {
+ snapshotChildQueries.incrementAndGet()
+ activeSnapshotCursors.incrementAndGet()
+ }
+
+ val children = fileFor(parentDocumentId).listFiles()?.sortedBy { it.name }.orEmpty()
+ val visibleChildren = if (loading) children.take(loadingChildLimit) else children
+ visibleChildren.forEach { child ->
+ include(cursor, "$parentDocumentId/${child.name}", child)
+ }
+ if (notificationOnly) {
+ notificationChildRows.addAndGet(visibleChildren.size)
+ } else {
+ snapshotChildRows.addAndGet(visibleChildren.size)
+ }
+ cursor.setNotificationUri(
+ context!!.contentResolver, childrenUriOf(parentDocumentId)
+ )
+ if (!notificationOnly) {
+ childSnapshotCaptured?.countDown()
+ childQueryReturnGate?.await(10, TimeUnit.SECONDS)
+ }
+ return cursor
+ }
+
+ override fun renameDocument(documentId: String, displayName: String): String {
+ val source = fileFor(documentId)
+ val parent = source.parentFile
+ ?: throw FileNotFoundException("Document has no parent: $documentId")
+ val target = File(parent, displayName)
+ if (!source.renameTo(target)) {
+ throw FileNotFoundException("Could not rename $documentId")
+ }
+
+ val parentDocumentId = documentId.substringBeforeLast('/', ROOT_ID)
+ context!!.contentResolver.notifyChange(childrenUriOf(parentDocumentId), null)
+ return "$parentDocumentId/${target.name}"
+ }
+
+ /** Child cursor with failure injection and close accounting. */
+ private class TrackingCursor(
+ columns: Array,
+ loading: Boolean,
+ private val notificationOnly: Boolean,
+ ) : MatrixCursor(columns) {
+
+ private val closedOnce = java.util.concurrent.atomic.AtomicBoolean(false)
+ private val cursorExtras = Bundle().apply {
+ putBoolean(DocumentsContract.EXTRA_LOADING, loading)
+ }
+
+ override fun getExtras(): Bundle = cursorExtras
+
+ override fun getCount(): Int {
+ if (revokeDuringMaterialization) {
+ throw SecurityException("Permission revoked while materializing rows (test)")
+ }
+ return super.getCount()
+ }
+
+ override fun registerContentObserver(observer: ContentObserver) {
+ if (failObserverRegistration) {
+ throw SecurityException("Permission revoked during observer registration (test)")
+ }
+ super.registerContentObserver(observer)
+ }
+
+ override fun close() {
+ childCursorCloseStarted?.countDown()
+ childCursorCloseGate?.await(10, TimeUnit.SECONDS)
+ if (closedOnce.compareAndSet(false, true)) {
+ closedChildCursors.incrementAndGet()
+ if (notificationOnly) activeNotificationCursors.decrementAndGet()
+ else activeSnapshotCursors.decrementAndGet()
+ }
+ super.close()
+ }
+ }
+
+ override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
+ documentId.startsWith("$parentDocumentId/")
+
+ override fun openDocument(
+ documentId: String,
+ mode: String,
+ signal: CancellationSignal?,
+ ): ParcelFileDescriptor = ParcelFileDescriptor.open(
+ fileFor(documentId), ParcelFileDescriptor.parseMode(mode)
+ )
+
+ private fun resolve(projection: Array?): Array =
+ projection ?: arrayOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_SIZE,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_FLAGS,
+ )
+
+ private fun include(cursor: MatrixCursor, documentId: String, file: File) {
+ val row = cursor.newRow()
+ cursor.columnNames.forEach { column ->
+ when (column) {
+ Document.COLUMN_DOCUMENT_ID -> row.add(column, documentId)
+ Document.COLUMN_DISPLAY_NAME -> row.add(column, file.name)
+ Document.COLUMN_SIZE -> row.add(column, file.length())
+ Document.COLUMN_LAST_MODIFIED -> row.add(column, file.lastModified())
+ Document.COLUMN_FLAGS -> row.add(column, 0)
+ Document.COLUMN_MIME_TYPE -> row.add(
+ column,
+ if (file.isDirectory) Document.MIME_TYPE_DIR else "application/octet-stream"
+ )
+ }
+ }
+ }
+
+ companion object {
+ const val AUTHORITY = "com.lazygeniouz.dfc.test.documents"
+ const val ROOT_ID = "root"
+
+ // Test controls for failure / blocking / measurement scenarios.
+ @Volatile
+ var failChildQueries = false
+
+ @Volatile
+ var failChildQueryAt: Int? = null
+
+ @Volatile
+ var revokePermissions = false
+
+ @Volatile
+ var revokeDuringMaterialization = false
+
+ @Volatile
+ var failObserverRegistration = false
+
+ @Volatile
+ var returnNullChildQueries = false
+
+ @Volatile
+ var returnNullDocumentQueries = false
+
+ @Volatile
+ var returnLoadingChildren = false
+
+ @Volatile
+ var loadingChildLimit = 1
+
+ @Volatile
+ var childQueryGate: CountDownLatch? = null
+
+ @Volatile
+ var childSnapshotCaptured: CountDownLatch? = null
+
+ @Volatile
+ var childQueryReturnGate: CountDownLatch? = null
+
+ @Volatile
+ var childCursorCloseStarted: CountDownLatch? = null
+
+ @Volatile
+ var childCursorCloseGate: CountDownLatch? = null
+
+ val childQueryCount = AtomicInteger(0)
+ val failedChildQueries = AtomicInteger(0)
+ val nullChildQueries = AtomicInteger(0)
+ val nullDocumentQueries = AtomicInteger(0)
+ val failNextChildQueries = AtomicInteger(0)
+ val openChildCursors = AtomicInteger(0)
+ val closedChildCursors = AtomicInteger(0)
+ val notificationChildQueries = AtomicInteger(0)
+ val snapshotChildQueries = AtomicInteger(0)
+ val notificationChildRows = AtomicInteger(0)
+ val snapshotChildRows = AtomicInteger(0)
+ val activeNotificationCursors = AtomicInteger(0)
+ val activeSnapshotCursors = AtomicInteger(0)
+
+ fun resetTestControls() {
+ failChildQueries = false
+ failChildQueryAt = null
+ failNextChildQueries.set(0)
+ revokePermissions = false
+ revokeDuringMaterialization = false
+ failObserverRegistration = false
+ returnNullChildQueries = false
+ returnNullDocumentQueries = false
+ returnLoadingChildren = false
+ loadingChildLimit = 1
+ childQueryGate?.countDown()
+ childQueryGate = null
+ childQueryReturnGate?.countDown()
+ childQueryReturnGate = null
+ childCursorCloseGate?.countDown()
+ childCursorCloseGate = null
+ childCursorCloseStarted = null
+ childSnapshotCaptured = null
+ }
+
+ private fun consumeNextChildQueryFailure(): Boolean {
+ while (true) {
+ val remaining = failNextChildQueries.get()
+ if (remaining <= 0) return false
+ if (failNextChildQueries.compareAndSet(remaining, remaining - 1)) return true
+ }
+ }
+
+ fun childrenUriOf(parentDocumentId: String) =
+ DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId)!!
+ }
+}
\ No newline at end of file
diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDifferTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDifferTest.kt
new file mode 100644
index 0000000..397144b
--- /dev/null
+++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDifferTest.kt
@@ -0,0 +1,276 @@
+package com.lazygeniouz.dfc.observer.internal.snapshot
+
+import android.os.CancellationSignal
+import android.os.OperationCanceledException
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.lazygeniouz.dfc.observer.DirectoryObserver
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for the observer diff engine.
+ */
+@RunWith(AndroidJUnit4::class)
+class SnapshotDifferTest {
+
+ private fun child(
+ id: String,
+ name: String = "$id.txt",
+ mime: String = "text/plain",
+ size: Long = 10L,
+ lastModified: Long = 100L,
+ flags: Int = 0,
+ ) = ChildState(id, name, size, lastModified, mime, flags)
+
+ private fun snapshotOf(vararg children: ChildState): LinkedHashMap {
+ val map = LinkedHashMap()
+ children.forEach { map[it.documentId] = it }
+ return map
+ }
+
+ private fun events(vararg children: Pair) = children.toList()
+
+ private fun List.simplified() =
+ map { it.event to it.child.documentId }
+
+ // region no change
+
+ @Test
+ fun noChange_emitsNothing() {
+ val old = snapshotOf(child("a"), child("b"))
+ val new = snapshotOf(child("a"), child("b"))
+ assertTrue(SnapshotDiffer.diff(old, new).isEmpty())
+ }
+
+ @Test
+ fun bothEmpty_emitsNothing() {
+ assertTrue(SnapshotDiffer.diff(LinkedHashMap(), LinkedHashMap()).isEmpty())
+ }
+
+ // endregion
+
+ // region create / delete / modify
+
+ @Test
+ fun newId_emitsCreate() {
+ val result = SnapshotDiffer.diff(snapshotOf(child("a")), snapshotOf(child("a"), child("b")))
+ assertEquals(events(DirectoryObserver.CREATE to "b"), result.simplified())
+ }
+
+ @Test
+ fun emptyOldToPopulated_emitsCreateForEverything() {
+ val result = SnapshotDiffer.diff(LinkedHashMap(), snapshotOf(child("a"), child("b")))
+ assertEquals(
+ events(DirectoryObserver.CREATE to "a", DirectoryObserver.CREATE to "b"),
+ result.simplified()
+ )
+ }
+
+ @Test
+ fun missingId_emitsDeleteWithLastKnownDocument() {
+ val result = SnapshotDiffer.diff(snapshotOf(child("a"), child("b")), snapshotOf(child("a")))
+ assertEquals(events(DirectoryObserver.DELETE to "b"), result.simplified())
+ assertEquals("b.txt", result.single().child.name)
+ }
+
+ @Test
+ fun sizeChange_emitsModify() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", size = 10L)),
+ snapshotOf(child("a", size = 20L)),
+ )
+ assertEquals(events(DirectoryObserver.MODIFY to "a"), result.simplified())
+ }
+
+ @Test
+ fun lastModifiedChange_emitsModify() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", lastModified = 100L)),
+ snapshotOf(child("a", lastModified = 200L)),
+ )
+ assertEquals(events(DirectoryObserver.MODIFY to "a"), result.simplified())
+ }
+
+ @Test
+ fun mimeTypeChange_emitsModify() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", mime = "text/plain")),
+ snapshotOf(child("a", mime = "application/json")),
+ )
+ assertEquals(events(DirectoryObserver.MODIFY to "a"), result.simplified())
+ }
+
+ @Test
+ fun flagsOnlyChange_emitsNothing() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", flags = 0)),
+ snapshotOf(child("a", flags = 1)),
+ )
+ assertTrue(result.isEmpty())
+ }
+
+ // endregion
+
+ // region rename semantics
+
+ @Test
+ fun sameIdRename_emitsMovedFromWithOldNameThenMovedToWithNewName() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", name = "old.txt")),
+ snapshotOf(child("a", name = "new.txt")),
+ )
+
+ assertEquals(
+ events(DirectoryObserver.MOVED_FROM to "a", DirectoryObserver.MOVED_TO to "a"),
+ result.simplified()
+ )
+ assertEquals("old.txt", result[0].child.name)
+ assertEquals("new.txt", result[1].child.name)
+ }
+
+ @Test
+ fun renamePlusMetadataChange_emitsMovePairAndModify() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", name = "old.txt", size = 10L, lastModified = 100L)),
+ snapshotOf(child("a", name = "new.txt", size = 99L, lastModified = 999L)),
+ )
+
+ assertEquals(
+ events(
+ DirectoryObserver.MOVED_FROM to "a",
+ DirectoryObserver.MOVED_TO to "a",
+ DirectoryObserver.MODIFY to "a",
+ ),
+ result.simplified()
+ )
+ }
+
+ @Test
+ fun renamePlusMetadataChange_withModifyOnly_emitsModify() {
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", name = "old.txt", size = 10L)),
+ snapshotOf(child("a", name = "new.txt", size = 20L)),
+ DirectoryObserver.MODIFY,
+ )
+
+ assertEquals(events(DirectoryObserver.MODIFY to "a"), result.simplified())
+ }
+
+ @Test
+ fun idChangingRename_emitsDeletePlusCreateNeverAMove() {
+ // Similar looking entries with different ids must NOT be paired into a move.
+ val result = SnapshotDiffer.diff(
+ snapshotOf(child("a", name = "file.txt")),
+ snapshotOf(child("b", name = "file (renamed).txt")),
+ )
+
+ assertEquals(
+ events(DirectoryObserver.DELETE to "a", DirectoryObserver.CREATE to "b"),
+ result.simplified()
+ )
+ }
+
+ // endregion
+
+ // region ordering & multiple simultaneous changes
+
+ @Test
+ fun multipleChanges_orderedDeletesThenRenamesAndModifiesThenCreates() {
+ val old = snapshotOf(
+ child("gone1"),
+ child("renamed", name = "before.txt"),
+ child("changed", size = 1L),
+ child("gone2"),
+ child("stable"),
+ )
+ val new = snapshotOf(
+ child("fresh1"),
+ child("renamed", name = "after.txt"),
+ child("changed", size = 2L),
+ child("stable"),
+ child("fresh2"),
+ )
+
+ val result = SnapshotDiffer.diff(old, new)
+
+ assertEquals(
+ events(
+ DirectoryObserver.DELETE to "gone1",
+ DirectoryObserver.DELETE to "gone2",
+ DirectoryObserver.MOVED_FROM to "renamed",
+ DirectoryObserver.MOVED_TO to "renamed",
+ DirectoryObserver.MODIFY to "changed",
+ DirectoryObserver.CREATE to "fresh1",
+ DirectoryObserver.CREATE to "fresh2",
+ ),
+ result.simplified()
+ )
+ }
+
+ // endregion
+
+ // region mask filtering
+
+ @Test
+ fun maskFiltering_keepsOnlyRequestedEvents() {
+ val old = snapshotOf(
+ child("gone"), child("renamed", name = "a.txt"), child("changed", size = 1L)
+ )
+ val new = snapshotOf(
+ child("renamed", name = "b.txt"), child("changed", size = 2L), child("fresh")
+ )
+
+ assertEquals(
+ events(DirectoryObserver.DELETE to "gone", DirectoryObserver.CREATE to "fresh"),
+ SnapshotDiffer.diff(
+ old, new, DirectoryObserver.CREATE or DirectoryObserver.DELETE
+ ).simplified()
+ )
+
+ assertEquals(
+ events(DirectoryObserver.MODIFY to "changed"),
+ SnapshotDiffer.diff(old, new, DirectoryObserver.MODIFY).simplified()
+ )
+
+ // The move pair can be filtered to either half individually.
+ assertEquals(
+ events(DirectoryObserver.MOVED_FROM to "renamed"),
+ SnapshotDiffer.diff(old, new, DirectoryObserver.MOVED_FROM).simplified()
+ )
+ assertEquals(
+ events(DirectoryObserver.MOVED_TO to "renamed"),
+ SnapshotDiffer.diff(old, new, DirectoryObserver.MOVED_TO).simplified()
+ )
+ }
+
+ @Test
+ fun cancelledDiff_abortsBeforeEmittingEvents() {
+ val cancellationSignal = CancellationSignal().apply { cancel() }
+ assertThrows(OperationCanceledException::class.java) {
+ SnapshotDiffer.diff(
+ snapshotOf(child("old")),
+ snapshotOf(child("new")),
+ cancellationSignal = cancellationSignal,
+ )
+ }
+ }
+
+ // endregion
+
+ // region constants sanity
+
+ @Test
+ fun eventConstants_aliasFileObserverBitValues() {
+ // inotify values are ABI-stable; guards against accidental constant edits.
+ assertEquals(0x00000002, DirectoryObserver.MODIFY)
+ assertEquals(0x00000040, DirectoryObserver.MOVED_FROM)
+ assertEquals(0x00000080, DirectoryObserver.MOVED_TO)
+ assertEquals(0x00000100, DirectoryObserver.CREATE)
+ assertEquals(0x00000200, DirectoryObserver.DELETE)
+ }
+
+ // endregion
+}
\ No newline at end of file
diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/resolver/ReadChildrenReuseTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/resolver/ReadChildrenReuseTest.kt
new file mode 100644
index 0000000..bced279
--- /dev/null
+++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/resolver/ReadChildrenReuseTest.kt
@@ -0,0 +1,179 @@
+package com.lazygeniouz.dfc.resolver
+
+import android.database.MatrixCursor
+import android.os.CancellationSignal
+import android.os.OperationCanceledException
+import android.provider.DocumentsContract.Document
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.lazygeniouz.dfc.observer.internal.snapshot.ChildState
+import com.lazygeniouz.dfc.observer.internal.snapshot.SnapshotScan
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for [ResolverCompat.readChildSnapshot] instance reuse: on a re-read of the same
+ * directory, unchanged rows must return the previous instance (no new allocation),
+ * changed rows must build a fresh object.
+ */
+@RunWith(AndroidJUnit4::class)
+class ReadChildrenReuseTest {
+
+ private fun row(
+ id: String,
+ name: String = "$id.txt",
+ size: Long = 10L,
+ lastModified: Long = 100L,
+ mime: String = "text/plain",
+ flags: Int = 0,
+ ): Array = arrayOf(id, name, size, lastModified, mime, flags)
+
+ private fun cursorOf(vararg rows: Array): MatrixCursor {
+ val cursor = MatrixCursor(
+ arrayOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_SIZE,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_FLAGS,
+ )
+ )
+ rows.forEach { cursor.addRow(it) }
+ return cursor
+ }
+
+ private fun read(
+ cursor: MatrixCursor,
+ reusable: Map = emptyMap(),
+ cancellationSignal: CancellationSignal = CancellationSignal(),
+ trackCreations: Boolean = false,
+ ): Map = readScan(
+ cursor, reusable, cancellationSignal, trackCreations
+ ).snapshot
+
+ private fun readScan(
+ cursor: MatrixCursor,
+ reusable: Map = emptyMap(),
+ cancellationSignal: CancellationSignal = CancellationSignal(),
+ trackCreations: Boolean = false,
+ ): SnapshotScan {
+ return cursor.use {
+ ResolverCompat.readChildSnapshot(
+ it, reusable, cancellationSignal, trackCreations
+ )
+ }
+ }
+
+ @Test
+ fun plainRead_keysByDocumentIdAndRetainsMetadata() {
+ val children = read(cursorOf(row("a"), row("b", mime = Document.MIME_TYPE_DIR)))
+
+ assertEquals(2, children.size)
+ assertEquals("a", children.getValue("a").documentId)
+ assertEquals("a.txt", children.getValue("a").name)
+ assertEquals(Document.MIME_TYPE_DIR, children.getValue("b").mimeType)
+ }
+
+ @Test
+ fun unchangedRows_reusePreviousInstances() {
+ val first = read(cursorOf(row("a"), row("b")))
+ val second = read(cursorOf(row("a"), row("b")), reusable = first)
+
+ assertSame(first.getValue("a"), second.getValue("a"))
+ assertSame(first.getValue("b"), second.getValue("b"))
+ }
+
+ @Test
+ fun changedRow_buildsFreshInstance_othersReused() {
+ val first = read(cursorOf(row("a"), row("b")))
+ val second = read(
+ cursorOf(row("a"), row("b", size = 99L, lastModified = 200L)),
+ reusable = first
+ )
+
+ assertSame(first.getValue("a"), second.getValue("a"))
+ assertNotSame(first.getValue("b"), second.getValue("b"))
+ assertEquals(99L, second.getValue("b").length)
+ }
+
+ @Test
+ fun renamedRow_buildsFreshInstance() {
+ val first = read(cursorOf(row("a", name = "old.txt")))
+ val second = read(cursorOf(row("a", name = "new.txt")), reusable = first)
+
+ assertNotSame(first.getValue("a"), second.getValue("a"))
+ assertEquals("old.txt", first.getValue("a").name)
+ assertEquals("new.txt", second.getValue("a").name)
+ }
+
+ @Test
+ fun flagsOnlyChange_buildsFreshInstance() {
+ // Flags are excluded from MODIFY events, but capabilities changed —
+ // the snapshot must carry the fresh flags even if no event is emitted.
+ val first = read(cursorOf(row("a", flags = 0)))
+ val second = read(cursorOf(row("a", flags = Document.FLAG_SUPPORTS_DELETE)), reusable = first)
+
+ assertNotSame(first.getValue("a"), second.getValue("a"))
+ }
+
+ @Test
+ fun mimeChange_buildsFreshState() {
+ val first = read(cursorOf(row("a", mime = "text/plain")))
+ val second = read(cursorOf(row("a", mime = Document.MIME_TYPE_DIR)), reusable = first)
+
+ assertNotSame(first.getValue("a"), second.getValue("a"))
+ assertEquals("text/plain", first.getValue("a").mimeType)
+ assertEquals(Document.MIME_TYPE_DIR, second.getValue("a").mimeType)
+ }
+
+ @Test
+ fun newAndRemovedRows_dontConfuseReuse() {
+ val first = read(cursorOf(row("a"), row("gone")))
+ val second = read(cursorOf(row("a"), row("fresh")), reusable = first)
+
+ assertSame(first.getValue("a"), second.getValue("a"))
+ assertEquals(setOf("a", "fresh"), second.keys)
+ }
+
+ @Test
+ fun creationTracking_collectsOnlyNewRowsDuringTheScan() {
+ val first = read(cursorOf(row("a"), row("gone")))
+ val second = readScan(
+ cursorOf(row("a"), row("fresh-1"), row("fresh-2")),
+ reusable = first,
+ trackCreations = true,
+ )
+
+ assertSame(first.getValue("a"), second.snapshot.getValue("a"))
+ assertEquals(
+ listOf("fresh-1", "fresh-2"),
+ second.creations.map(ChildState::documentId),
+ )
+ }
+
+ @Test
+ fun malformedDocumentId_rejectsTheWholeSnapshot() {
+ assertThrows(IllegalStateException::class.java) {
+ read(cursorOf(row("")))
+ }
+ }
+
+ @Test
+ fun duplicateDocumentId_rejectsTheWholeSnapshot() {
+ assertThrows(IllegalStateException::class.java) {
+ read(cursorOf(row("a"), row("a", name = "duplicate.txt")))
+ }
+ }
+
+ @Test
+ fun cancelledRead_abortsBeforeBuildingTheSnapshot() {
+ val cancellationSignal = CancellationSignal().apply { cancel() }
+ assertThrows(OperationCanceledException::class.java) {
+ read(cursorOf(row("a")), cancellationSignal = cancellationSignal)
+ }
+ }
+}
\ No newline at end of file
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..6ae02df 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt
@@ -9,6 +9,8 @@ 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.observer.DirectoryEventMask
+import com.lazygeniouz.dfc.observer.DirectoryObserver
import java.io.File
/**
@@ -195,13 +197,40 @@ abstract class DocumentFileCompat(
}
/**
- * Converts a non serializable [DocumentFileCompat] to a serializable [SerializedFile].
+ * Converts a non-serializable [DocumentFileCompat] to a serializable [SerializedFile].
*/
@Suppress("MemberVisibilityCanBePrivate")
fun serialize(): SerializedFile {
return SerializedFile.from(this)
}
+ /**
+ * Creates a stopped [DirectoryObserver] for this SAF directory's direct children.
+ *
+ * Existing children emit no events. Changes may coalesce between provider notifications,
+ * and callbacks run on a worker thread with detached document snapshots.
+ *
+ * @param mask Bitwise OR of the events to receive, [DirectoryObserver.ALL_EVENTS] by default.
+ * @throws UnsupportedOperationException if this is not a SAF-backed directory.
+ * @throws IllegalArgumentException if [mask] is empty or contains an unsupported event.
+ */
+ fun observe(
+ @DirectoryEventMask mask: Int = DirectoryObserver.ALL_EVENTS,
+ listener: (event: Int, document: DocumentFileCompat) -> Unit,
+ ): DirectoryObserver {
+ if (this !is TreeDocumentFileCompat || !isDirectory()) {
+ throw UnsupportedOperationException(
+ "observe() requires a directory backed by a SAF tree uri."
+ )
+ }
+
+ require(mask != 0 && (mask and DirectoryObserver.ALL_EVENTS) == mask) {
+ "The mask must contain only supported observer events."
+ }
+
+ return DirectoryObserver.create(this, mask, listener)
+ }
+
companion object {
/**
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..ae60c3e 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
@@ -115,7 +115,7 @@ internal class RawDocumentFileCompat(context: Context, var file: File) :
/**
* This will return the children count in the directory.
*
- * [File] api is usually fast but may slow down if there are a lot of children in the directory..
+ * [File] api is usually fast but may slow down if there are a lot of children in the directory.
*
*/
override fun count(): Int {
@@ -135,7 +135,7 @@ internal class RawDocumentFileCompat(context: Context, var file: File) :
file.inputStream().use { inputStream -> inputStream.copyTo(outPutStream) }
}
- // Copies current source file at current uri's location.
+ // Copies current source file at current Uri's location.
override fun copyFrom(source: Uri) {
val inputStream = context.contentResolver.openInputStream(source)!!
inputStream.use { stream -> stream.copyTo(file.outputStream()) }
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..44d0ab7 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/logger/ErrorLogger.kt
@@ -11,6 +11,12 @@ object ErrorLogger {
* Log error to the logcat to let the developer know if something went wrong.
*/
internal fun logError(message: String, throwable: Throwable?) {
- Log.e("DocumentFileCompat", "$message: ${throwable?.message}")
+ if (throwable == null) {
+ Log.e(TAG, message)
+ } else {
+ Log.e(TAG, message, throwable)
+ }
}
+
+ private const val TAG = "DocumentFileCompat"
}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryEventMask.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryEventMask.kt
new file mode 100644
index 0000000..c25a2eb
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryEventMask.kt
@@ -0,0 +1,24 @@
+package com.lazygeniouz.dfc.observer
+
+import androidx.annotation.IntDef
+
+/** Restricts directory observation masks to the supported [DirectoryObserver] event flags. */
+@MustBeDocumented
+@Retention(AnnotationRetention.SOURCE)
+@Target(
+ AnnotationTarget.FIELD,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.LOCAL_VARIABLE,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.TYPE,
+ AnnotationTarget.VALUE_PARAMETER,
+)
+@IntDef(
+ DirectoryObserver.MODIFY,
+ DirectoryObserver.MOVED_FROM,
+ DirectoryObserver.MOVED_TO,
+ DirectoryObserver.CREATE,
+ DirectoryObserver.DELETE,
+ flag = true,
+)
+annotation class DirectoryEventMask
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryObserver.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryObserver.kt
new file mode 100644
index 0000000..af0cb7d
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryObserver.kt
@@ -0,0 +1,66 @@
+package com.lazygeniouz.dfc.observer
+
+import android.os.FileObserver
+import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.observer.internal.watcher.DirectoryWatcher
+import java.io.Closeable
+
+/**
+ * A direct-child observation created by [com.lazygeniouz.dfc.file.DocumentFileCompat.observe].
+ * Starting and stopping are idempotent and thread-safe; [stopWatching] is safe inside callbacks.
+ * Close it with the owning lifecycle.
+ */
+class DirectoryObserver private constructor(
+ directory: DocumentFileCompat,
+ @DirectoryEventMask mask: Int,
+ listener: (event: Int, document: DocumentFileCompat) -> Unit,
+) : Closeable {
+
+ private val watcher = DirectoryWatcher(directory, mask, listener)
+
+ /**
+ * Starts watching. [onReady] follows a successful baseline; [onError] reports terminal
+ * startup, refresh, permission, or directory failures. Starts are ignored while active, while
+ * a stop drains a callback, or until a terminal callback returns.
+ */
+ fun startWatching(
+ onError: (Throwable) -> Unit = {},
+ onReady: () -> Unit = {},
+ ) = watcher.startWatching(onError, onReady)
+
+ /**
+ * Invalidates the session and prevents further callbacks before returning. Cleanup
+ * completes on the worker after any provider operation already in flight returns.
+ */
+ fun stopWatching() = watcher.stopWatching()
+
+ override fun close() = stopWatching()
+
+ companion object {
+
+ @JvmSynthetic
+ internal fun create(
+ directory: DocumentFileCompat,
+ @DirectoryEventMask mask: Int,
+ listener: (event: Int, document: DocumentFileCompat) -> Unit,
+ ) = DirectoryObserver(directory, mask, listener)
+
+ /** A child's size, last-modified time, or MIME type changed. */
+ const val MODIFY = FileObserver.MODIFY
+
+ /** A rename's old state when its document ID stays stable; otherwise DELETE + CREATE. */
+ const val MOVED_FROM = FileObserver.MOVED_FROM
+
+ /** A rename's new state when its document ID stays stable; otherwise DELETE + CREATE. */
+ const val MOVED_TO = FileObserver.MOVED_TO
+
+ /** Same as [FileObserver.CREATE]: a child was created. */
+ const val CREATE = FileObserver.CREATE
+
+ /** Same as [FileObserver.DELETE]: a child was deleted. */
+ const val DELETE = FileObserver.DELETE
+
+ /** Every event directory observation can produce. */
+ const val ALL_EVENTS = MODIFY or MOVED_FROM or MOVED_TO or CREATE or DELETE
+ }
+}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/ChildState.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/ChildState.kt
new file mode 100644
index 0000000..ed91cc3
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/ChildState.kt
@@ -0,0 +1,38 @@
+package com.lazygeniouz.dfc.observer.internal.snapshot
+
+/** Minimal immutable metadata retained for one observed child. */
+internal class ChildState(
+ val documentId: String,
+ val name: String,
+ val length: Long,
+ val lastModified: Long,
+ val mimeType: String,
+ val flags: Int,
+) {
+
+ /**
+ * Compares cursor values without allocating another [ChildState].
+ */
+ fun matches(
+ name: String,
+ length: Long,
+ lastModified: Long,
+ mimeType: String,
+ flags: Int,
+ ): Boolean {
+ return this.name == name
+ && this.length == length
+ && this.lastModified == lastModified
+ && this.mimeType == mimeType
+ && this.flags == flags
+ }
+
+ /**
+ * Name changes emit move events; flag-only changes emit no event.
+ */
+ fun isModified(other: ChildState): Boolean {
+ return length != other.length
+ || lastModified != other.lastModified
+ || mimeType != other.mimeType
+ }
+}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/DiffEvent.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/DiffEvent.kt
new file mode 100644
index 0000000..be8f166
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/DiffEvent.kt
@@ -0,0 +1,7 @@
+package com.lazygeniouz.dfc.observer.internal.snapshot
+
+/** One event derived from two complete directory snapshots. */
+internal class DiffEvent(
+ val event: Int,
+ val child: ChildState,
+)
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDiffer.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDiffer.kt
new file mode 100644
index 0000000..d968f0f
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotDiffer.kt
@@ -0,0 +1,111 @@
+package com.lazygeniouz.dfc.observer.internal.snapshot
+
+import android.os.CancellationSignal
+import com.lazygeniouz.dfc.observer.DirectoryEventMask
+import com.lazygeniouz.dfc.observer.DirectoryObserver
+
+/**
+ * Conservative by design: an id-changing rename surfaces as DELETE + CREATE, similar
+ * entries are never paired into moves. Order: DELETEs, then renames / modifies, then CREATEs.
+ */
+internal object SnapshotDiffer {
+
+ /** Diff [old] against [new]; empty list when nothing changed. */
+ internal fun diff(
+ old: Map,
+ new: Map,
+ @DirectoryEventMask mask: Int = DirectoryObserver.ALL_EVENTS,
+ cancellationSignal: CancellationSignal? = null,
+ scannedCreations: List? = null,
+ ): List {
+ if (old.isEmpty() && new.isEmpty()) return emptyList()
+
+ val deletions = ArrayList()
+ val changes = ArrayList()
+ if (mask and OLD_SNAPSHOT_EVENTS != 0) {
+ collectOldSnapshotEvents(
+ old, new, mask, deletions, changes, cancellationSignal
+ )
+ }
+
+ val creations: List
+ if (mask includes DirectoryObserver.CREATE) {
+ creations = scannedCreations
+ ?: collectCreations(old, new, cancellationSignal)
+ } else {
+ creations = emptyList()
+ }
+
+ cancellationSignal?.throwIfCanceled()
+
+ if (deletions.isEmpty() && changes.isEmpty() && creations.isEmpty()) return emptyList()
+ val events = ArrayList(deletions.size + changes.size + creations.size)
+ events.addAll(deletions)
+ events.addAll(changes)
+ for (child in creations) events.add(DiffEvent(DirectoryObserver.CREATE, child))
+ return events
+ }
+
+ // Deletions and changes share one old-snapshot traversal but retain their group ordering.
+ private fun collectOldSnapshotEvents(
+ old: Map,
+ new: Map,
+ mask: Int,
+ deletions: MutableList,
+ changes: MutableList,
+ cancellationSignal: CancellationSignal?,
+ ) {
+ var row = 0
+ for ((documentId, oldChild) in old) {
+ if ((row++ and CANCELLATION_CHECK_MASK) == 0) cancellationSignal?.throwIfCanceled()
+ val newChild = new[documentId]
+ if (newChild == null) {
+ if (mask includes DirectoryObserver.DELETE) {
+ deletions.add(DiffEvent(DirectoryObserver.DELETE, oldChild))
+ }
+ continue
+ }
+
+ // Reused instance == unchanged row (snapshot reads reuse only identical fields).
+ if (oldChild === newChild) continue
+
+ if (oldChild.name != newChild.name) {
+ if (mask includes DirectoryObserver.MOVED_FROM) {
+ changes.add(DiffEvent(DirectoryObserver.MOVED_FROM, oldChild))
+ }
+ if (mask includes DirectoryObserver.MOVED_TO) {
+ changes.add(DiffEvent(DirectoryObserver.MOVED_TO, newChild))
+ }
+ }
+ if (
+ mask includes DirectoryObserver.MODIFY && oldChild.isModified(newChild)
+ ) {
+ changes.add(DiffEvent(DirectoryObserver.MODIFY, newChild))
+ }
+ }
+ }
+
+ // In new snapshot order.
+ private fun collectCreations(
+ old: Map,
+ new: Map,
+ cancellationSignal: CancellationSignal?,
+ ): List {
+ val creations = ArrayList()
+ var row = 0
+ for ((documentId, newChild) in new) {
+ if ((row++ and CANCELLATION_CHECK_MASK) == 0) cancellationSignal?.throwIfCanceled()
+ if (documentId !in old) {
+ creations.add(newChild)
+ }
+ }
+ return creations
+ }
+
+ private infix fun Int.includes(event: Int): Boolean = and(event) != 0
+
+ private const val CHANGE_EVENTS =
+ DirectoryObserver.MOVED_FROM or DirectoryObserver.MOVED_TO or DirectoryObserver.MODIFY
+ private const val OLD_SNAPSHOT_EVENTS = CHANGE_EVENTS or DirectoryObserver.DELETE
+ private const val CANCELLATION_CHECK_MASK = 63
+}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotScan.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotScan.kt
new file mode 100644
index 0000000..1d31bec
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/snapshot/SnapshotScan.kt
@@ -0,0 +1,7 @@
+package com.lazygeniouz.dfc.observer.internal.snapshot
+
+/** A complete cursor scan plus creations already identified during that scan. */
+internal class SnapshotScan(
+ val snapshot: LinkedHashMap,
+ val creations: List,
+)
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/DirectoryWatcher.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/DirectoryWatcher.kt
new file mode 100644
index 0000000..46d5e24
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/DirectoryWatcher.kt
@@ -0,0 +1,403 @@
+package com.lazygeniouz.dfc.observer.internal.watcher
+
+import android.database.ContentObserver
+import android.database.Cursor
+import android.os.Looper
+import android.os.OperationCanceledException
+import android.provider.DocumentsContract
+import com.lazygeniouz.dfc.file.DocumentFileCompat
+import com.lazygeniouz.dfc.logger.ErrorLogger
+import com.lazygeniouz.dfc.observer.DirectoryEventMask
+import com.lazygeniouz.dfc.observer.DirectoryObserver
+import com.lazygeniouz.dfc.observer.internal.snapshot.ChildState
+import com.lazygeniouz.dfc.observer.internal.snapshot.DiffEvent
+import com.lazygeniouz.dfc.observer.internal.snapshot.SnapshotDiffer
+import com.lazygeniouz.dfc.observer.internal.snapshot.SnapshotScan
+import com.lazygeniouz.dfc.resolver.ResolverCompat
+import java.io.FileNotFoundException
+import java.io.IOException
+
+/**
+ * Event driven watcher for the **direct children** of a SAF directory, zero polling.
+ *
+ * AOSP refcounts its internal `FileObserver` against **active** directory cursors, so one
+ * lightweight [Cursor] stays alive for notifications while full snapshot cursors are temporary.
+ *
+ * Each start..stop cycle owns a [WatchSession] that can only release its own resources; cursor
+ * & snapshot state is confined to its worker thread & the listener runs there too.
+ * [startWatching] / [stopWatching] are idempotent & callable from any thread.
+ */
+internal class DirectoryWatcher(
+ private val directory: DocumentFileCompat,
+ @param:DirectoryEventMask private val mask: Int,
+ private val listener: (event: Int, document: DocumentFileCompat) -> Unit,
+) {
+
+ private val lock = Any()
+
+ @Volatile
+ // Writes are guarded by [lock]; volatile reads make stale sessions no-op.
+ private var session: WatchSession? = null
+
+ // Keeps the callback barrier visible to concurrent stop calls.
+ private var stoppingSession: WatchSession? = null
+
+ // A terminal session remains visible until its onError callback is completed or suppressed.
+ private var terminatingSession: WatchSession? = null
+
+ private val WatchSession.isCurrent get() = this@DirectoryWatcher.session === this
+
+ /** Start watching; no-op if already watching. */
+ internal fun startWatching(
+ onError: (Throwable) -> Unit,
+ onReady: () -> Unit,
+ ) {
+ synchronized(lock) {
+ if (session != null || stoppingSession != null || terminatingSession != null) return
+ val started = WatchSession(::scheduleRefresh, onError, onReady)
+ session = started
+ started.handler.post { initialize(started) }
+ }
+ }
+
+ /** Stop watching & release the session's cursor + worker thread; no-op if not watching. */
+ internal fun stopWatching() {
+ val stopped: WatchSession
+ val teardownRequired: Boolean
+ synchronized(lock) {
+ val active = session
+ if (active != null) {
+ stopped = active
+ session = null
+ stoppingSession = active
+ teardownRequired = true
+ } else {
+ stopped = stoppingSession ?: terminatingSession ?: return
+ teardownRequired = false
+ }
+ stopped.suppressTerminalCallback.set(true)
+ }
+ stopped.cancellationSignal.cancel()
+ // An admitted callback finishes before stop returns; a pending terminal callback is skipped.
+ synchronized(stopped.callbackLock) {}
+ if (teardownRequired) {
+ stopped.handler.post {
+ // Reaching this message proves a self-stopping callback has returned.
+ synchronized(lock) {
+ if (stoppingSession === stopped) stoppingSession = null
+ }
+ teardown(stopped)
+ }
+ }
+
+ // A self-stop leaves the barrier until the worker advances past its callback.
+ if (Looper.myLooper() !== stopped.handler.looper) {
+ synchronized(lock) {
+ if (stoppingSession === stopped) stoppingSession = null
+ if (terminatingSession === stopped) terminatingSession = null
+ }
+ }
+ }
+
+ private fun scheduleRefresh(session: WatchSession) {
+ if (!session.isCurrent) return
+ if (session.refreshScheduled.compareAndSet(false, true)) {
+ session.handler.post { performRefresh(session) }
+ }
+ }
+
+ // Register for changes before capturing the baseline; pre-ready children are not emitted.
+ private fun initialize(session: WatchSession) {
+ if (!session.isCurrent) return
+ try {
+ doInitialize(session)
+ } catch (cancelled: OperationCanceledException) {
+ if (session.isCurrent) {
+ ErrorLogger.logError("Directory observer initialization was cancelled", cancelled)
+ stopSelf(session, cancelled)
+ }
+ } catch (security: SecurityException) {
+ ErrorLogger.logError("Permission revoked, observer is stopped", security)
+ stopSelf(session, security)
+ } catch (exception: Exception) {
+ ErrorLogger.logError("Could not initialize the directory observer", exception)
+ stopSelf(session, exception)
+ }
+ }
+
+ private fun doInitialize(session: WatchSession) {
+ installNotificationCursor(session)
+ if (!captureBaseline(session)) scheduleRefresh(session)
+ }
+
+ private fun performRefresh(session: WatchSession) {
+ session.refreshScheduled.set(false)
+ if (!session.isCurrent) return
+ try {
+ val completed = if (session.ready) {
+ refreshOnce(session)
+ } else {
+ captureBaseline(session)
+ }
+ if (completed) {
+ session.consecutiveRefreshFailures = 0
+ session.retryGeneration++
+ }
+ } catch (cancelled: OperationCanceledException) {
+ if (session.isCurrent) {
+ if (session.ready) {
+ handleRefreshFailure(session, "Directory refresh was cancelled", cancelled)
+ } else {
+ ErrorLogger.logError("Directory observer initialization was cancelled", cancelled)
+ stopSelf(session, cancelled)
+ }
+ }
+ } catch (security: SecurityException) {
+ ErrorLogger.logError("Permission revoked, releasing the observer", security)
+ stopSelf(session, security)
+ } catch (unavailable: FileNotFoundException) {
+ ErrorLogger.logError("Observed directory is no longer available", unavailable)
+ stopSelf(session, unavailable)
+ } catch (exception: Exception) {
+ if (session.ready) {
+ handleRefreshFailure(session, "Directory refresh failed", exception)
+ } else {
+ ErrorLogger.logError("Could not initialize the directory observer", exception)
+ stopSelf(session, exception)
+ }
+ }
+ }
+
+ private fun captureBaseline(session: WatchSession): Boolean {
+ val cursor = query(session, ResolverCompat.fullProjection)
+ ?: throw IOException("The provider returned no directory cursor")
+
+ val baseline = try {
+ session.cancellationSignal.throwIfCanceled()
+ if (cursor.isLoading()) return false
+ readSnapshot(session, cursor, trackCreations = false).snapshot
+ } finally {
+ release(cursor)
+ }
+
+ ensureDirectoryAvailable(session, baseline)
+ session.cancellationSignal.throwIfCanceled()
+ if (!session.isCurrent) return false
+
+ session.snapshot = baseline
+ session.ready = true
+ emitReady(session)
+ return session.isCurrent
+ }
+
+ private fun installNotificationCursor(session: WatchSession) {
+ val cursor = query(session, ResolverCompat.notificationProjection)
+ ?: throw IOException("The provider returned no notification cursor")
+
+ var attached = false
+ try {
+ session.cancellationSignal.throwIfCanceled()
+ cursor.registerContentObserver(session.observer)
+ session.cancellationSignal.throwIfCanceled()
+ if (!session.isCurrent) return
+
+ session.notificationCursor = cursor
+ attached = true
+ } finally {
+ if (!attached) release(cursor, session.observer)
+ }
+ }
+
+ // Re-query and atomically reconcile; incomplete results leave the committed state untouched.
+ private fun refreshOnce(session: WatchSession): Boolean {
+ if (session.notificationCursor == null) return true
+
+ val freshCursor = query(session, ResolverCompat.fullProjection)
+ ?: throw IOException("The provider returned no directory cursor")
+
+ val freshScan = try {
+ session.cancellationSignal.throwIfCanceled()
+ if (freshCursor.isLoading()) return false
+ readSnapshot(
+ session, freshCursor, trackCreations = mask includes DirectoryObserver.CREATE
+ )
+ } finally {
+ release(freshCursor)
+ }
+
+ ensureDirectoryAvailable(session, freshScan.snapshot)
+ session.cancellationSignal.throwIfCanceled()
+ if (!session.isCurrent) return true
+
+ val events = SnapshotDiffer.diff(
+ session.snapshot,
+ freshScan.snapshot,
+ mask,
+ session.cancellationSignal,
+ freshScan.creations,
+ )
+ session.cancellationSignal.throwIfCanceled()
+ if (!session.isCurrent) return true
+
+ session.snapshot = freshScan.snapshot
+
+ for (diffEvent in events) {
+ if (!emit(session, diffEvent)) break
+ }
+ return true
+ }
+
+ private fun query(session: WatchSession, projection: Array): Cursor? {
+ return directory.context.contentResolver.query(
+ ResolverCompat.createChildrenUri(directory.uri),
+ projection,
+ null, null, null,
+ session.cancellationSignal,
+ )
+ }
+
+ private fun emit(session: WatchSession, diffEvent: DiffEvent): Boolean {
+ return synchronized(session.callbackLock) {
+ if (!session.isCurrent) return@synchronized false
+ try {
+ listener(
+ diffEvent.event,
+ ResolverCompat.materializeChild(
+ directory.context, directory, diffEvent.child
+ )
+ )
+ } catch (exception: Exception) {
+ ErrorLogger.logError("Observer listener threw an exception", exception)
+ }
+ session.isCurrent
+ }
+ }
+
+ private fun readSnapshot(
+ session: WatchSession,
+ cursor: Cursor,
+ trackCreations: Boolean,
+ ): SnapshotScan {
+ return ResolverCompat.readChildSnapshot(
+ cursor,
+ session.snapshot,
+ session.cancellationSignal,
+ trackCreations,
+ )
+ }
+
+ private fun emitReady(session: WatchSession) {
+ synchronized(session.callbackLock) {
+ if (!session.isCurrent) return
+ try {
+ session.onReady()
+ } catch (exception: Exception) {
+ ErrorLogger.logError("Observer readiness callback threw an exception", exception)
+ }
+ }
+ }
+
+ private fun ensureDirectoryAvailable(
+ session: WatchSession,
+ snapshot: Map,
+ ) {
+ if (snapshot.isEmpty() && !ResolverCompat.isExistingDirectory(
+ directory.context,
+ directory.uri,
+ session.cancellationSignal,
+ )
+ ) {
+ throw FileNotFoundException("The observed directory no longer exists")
+ }
+ }
+
+ private fun handleRefreshFailure(
+ session: WatchSession,
+ message: String,
+ failure: Exception,
+ ) {
+ if (!session.isCurrent) return
+ if (scheduleRetryAfterFailure(session)) {
+ ErrorLogger.logError("$message; retrying once", failure)
+ return
+ }
+
+ ErrorLogger.logError("$message; retry exhausted, observer is stopped", failure)
+ stopSelf(session, failure)
+ }
+
+ private fun scheduleRetryAfterFailure(session: WatchSession): Boolean {
+ val failureCount = ++session.consecutiveRefreshFailures
+ val generation = ++session.retryGeneration
+ if (failureCount > MAX_AUTOMATIC_REFRESH_RETRIES || !session.isCurrent) return false
+
+ return session.handler.postDelayed({
+ if (session.isCurrent && session.retryGeneration == generation) {
+ scheduleRefresh(session)
+ }
+ }, REFRESH_RETRY_DELAY_MS)
+ }
+
+ // Worker-side stop for unrecoverable start failures; loses to an already issued stop.
+ private fun stopSelf(session: WatchSession, failure: Throwable) {
+ synchronized(lock) {
+ if (!session.isCurrent) return
+ this.session = null
+ terminatingSession = session
+ }
+ session.cancellationSignal.cancel()
+ try {
+ teardown(session)
+ synchronized(session.callbackLock) {
+ if (!session.suppressTerminalCallback.get()) {
+ try {
+ session.onError(failure)
+ } catch (exception: Exception) {
+ ErrorLogger.logError("Observer error callback threw an exception", exception)
+ }
+ }
+ }
+ } finally {
+ synchronized(lock) {
+ if (terminatingSession === session) terminatingSession = null
+ }
+ }
+ }
+
+ // Runs on the session's worker, always last: releases its resources & quits the looper.
+ private fun teardown(session: WatchSession) {
+ val cursor = session.notificationCursor
+ session.notificationCursor = null
+ session.snapshot = LinkedHashMap()
+ session.ready = false
+
+ if (cursor != null) release(cursor, session.observer)
+
+ Looper.myLooper()?.quitSafely()
+ }
+
+ // The single exception-safe cursor cleanup; unregisters when this observer was attached.
+ private fun release(cursor: Cursor, observer: ContentObserver? = null) {
+ if (observer != null) {
+ try {
+ cursor.unregisterContentObserver(observer)
+ } catch (_: Exception) {
+ }
+ }
+ try {
+ cursor.close()
+ } catch (_: Exception) {
+ }
+ }
+
+ private companion object {
+ const val MAX_AUTOMATIC_REFRESH_RETRIES = 1
+ const val REFRESH_RETRY_DELAY_MS = 200L
+ }
+
+ private infix fun Int.includes(event: Int): Boolean = and(event) != 0
+
+ private fun Cursor.isLoading(): Boolean {
+ return extras.getBoolean(DocumentsContract.EXTRA_LOADING, false)
+ }
+}
\ No newline at end of file
diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/WatchSession.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/WatchSession.kt
new file mode 100644
index 0000000..c3e03a3
--- /dev/null
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/internal/watcher/WatchSession.kt
@@ -0,0 +1,57 @@
+package com.lazygeniouz.dfc.observer.internal.watcher
+
+import android.database.ContentObserver
+import android.database.Cursor
+import android.os.CancellationSignal
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Process
+import com.lazygeniouz.dfc.observer.internal.snapshot.ChildState
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Everything owned by one start..stop cycle of a [DirectoryWatcher]: worker thread, observer,
+ * cancellation signal, cursor & snapshot. A stale session can only ever release its own
+ * resources, so rapid stop -> start cannot cross-close a newer session's cursor.
+ *
+ * [onChanged] is invoked on the notifier's thread for every provider notification.
+ */
+internal class WatchSession(
+ onChanged: (WatchSession) -> Unit,
+ val onError: (Throwable) -> Unit,
+ val onReady: () -> Unit,
+) {
+
+ // Background priority: housekeeping work should not compete with UI-critical threads.
+ private val thread = HandlerThread(
+ THREAD_NAME,
+ Process.THREAD_PRIORITY_BACKGROUND
+ ).apply { start() }
+ val handler = Handler(thread.looper)
+ val cancellationSignal = CancellationSignal()
+
+ // Gate: at most one pending refresh, even during notification storms.
+ val refreshScheduled = AtomicBoolean(false)
+
+ // Serializes event/readiness callbacks with stopWatching().
+ val callbackLock = Any()
+
+ // Set by an explicit stop that races a terminal worker-side stop.
+ val suppressTerminalCallback = AtomicBoolean(false)
+
+ // Handler-less: onChange only gates + posts, no per-notification worker messages.
+ val observer = object : ContentObserver(null) {
+ override fun onChange(selfChange: Boolean) = onChanged(this@WatchSession)
+ }
+
+ // Confined to [thread].
+ var notificationCursor: Cursor? = null
+ var snapshot: LinkedHashMap = LinkedHashMap()
+ var ready = false
+ var consecutiveRefreshFailures = 0
+ var retryGeneration = 0L
+
+ private companion object {
+ const val THREAD_NAME = "dfc-observer"
+ }
+}
\ 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..87e8e5f 100644
--- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt
+++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt
@@ -4,19 +4,23 @@ import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
+import android.os.CancellationSignal
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import com.lazygeniouz.dfc.file.DocumentFileCompat
import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat
import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat
import com.lazygeniouz.dfc.logger.ErrorLogger
+import com.lazygeniouz.dfc.observer.internal.snapshot.ChildState
+import com.lazygeniouz.dfc.observer.internal.snapshot.SnapshotScan
+import java.io.IOException
/**
* Helper class for calling relevant methods on [DocumentsContract] & queries via [ContentResolver].
*/
internal object ResolverCompat {
- private val iconProjection = arrayOf(Document.COLUMN_ICON)
+ internal val notificationProjection = arrayOf(Document.COLUMN_ICON)
private val idProjection = arrayOf(Document.COLUMN_DOCUMENT_ID)
val fullProjection = arrayOf(
Document.COLUMN_DOCUMENT_ID,
@@ -96,7 +100,7 @@ internal object ResolverCompat {
return getCursor(
context,
childrenUri,
- iconProjection
+ notificationProjection
)?.use { cursor -> return cursor.count } ?: 0
}
@@ -122,9 +126,7 @@ internal object ResolverCompat {
file: DocumentFileCompat,
projection: Array = fullProjection,
): List {
- val uri = file.uri
- val childrenUri = createChildrenUri(uri)
- val listOfDocuments = arrayListOf()
+ val childrenUri = createChildrenUri(file.uri)
val finalProjection = arrayOf(
Document.COLUMN_DOCUMENT_ID, /* identifier */
@@ -133,64 +135,179 @@ internal object ResolverCompat {
).distinct().toTypedArray()
val cursor = getCursor(context, childrenUri, finalProjection) ?: return emptyList()
+ return cursor.use { readChildren(context, it, file) }
+ }
+
+ /** Read direct children from [cursor], advancing it without closing it. */
+ internal fun readChildren(
+ context: Context,
+ cursor: Cursor,
+ parent: DocumentFileCompat,
+ ): List {
+ val itemCount = cursor.count
+ val listOfDocuments = arrayListOf()
+ if (itemCount > 10) listOfDocuments.ensureCapacity(itemCount)
+
+ forEachChildRow(cursor, null, strictIds = false) {
+ documentId, name, size, lastModified, mimeType, flags ->
+ listOfDocuments.add(
+ buildChild(
+ context, parent, documentId, name,
+ size, lastModified, mimeType, flags
+ )
+ )
+ }
+ return listOfDocuments
+ }
+
+ /**
+ * Reads an observer snapshot directly into a map keyed by document id. Unchanged rows reuse
+ * their previous compact state; malformed or duplicate ids reject the entire scan so malformed
+ * rows cannot be interpreted as deletions.
+ */
+ internal fun readChildSnapshot(
+ cursor: Cursor,
+ reusable: Map,
+ cancellationSignal: CancellationSignal,
+ trackCreations: Boolean,
+ ): SnapshotScan {
+ cancellationSignal.throwIfCanceled()
+ val itemCount = cursor.count
+ cancellationSignal.throwIfCanceled()
+ val snapshot = LinkedHashMap(mapCapacity(itemCount))
+ val creations = if (trackCreations) ArrayList() else null
+ forEachChildRow(cursor, cancellationSignal, strictIds = true) {
+ documentId, name, size, lastModified, mimeType, flags ->
+ val previous = reusable[documentId]
+ val child = if (previous != null && previous.matches(
+ name, size, lastModified, mimeType, flags
+ )
+ ) {
+ previous
+ } else {
+ ChildState(
+ previous?.documentId ?: documentId,
+ if (previous?.name == name) previous.name else name,
+ size,
+ lastModified,
+ if (previous?.mimeType == mimeType) previous.mimeType else mimeType,
+ flags,
+ )
+ }
+
+ check(snapshot.put(child.documentId, child) == null) {
+ "Directory query returned duplicate document id: $documentId"
+ }
+ if (previous == null) creations?.add(child)
+ }
+ return SnapshotScan(snapshot, creations ?: emptyList())
+ }
+
+ /** Query the watched document itself when an empty child cursor cannot prove it still exists. */
+ internal fun isExistingDirectory(
+ context: Context,
+ uri: Uri,
+ cancellationSignal: CancellationSignal,
+ ): Boolean {
+ cancellationSignal.throwIfCanceled()
+ val cursor = context.contentResolver.query(
+ uri,
+ arrayOf(Document.COLUMN_MIME_TYPE),
+ null, null, null,
+ cancellationSignal,
+ ) ?: throw IOException("The provider returned no document cursor")
+
+ return cursor.use {
+ cancellationSignal.throwIfCanceled()
+ val mimeIndex = it.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)
+ it.moveToFirst() && getStringOrDefault(it, mimeIndex) == Document.MIME_TYPE_DIR
+ }
+ }
+
+ /** Materializes a detached callback document from compact observer state. */
+ internal fun materializeChild(
+ context: Context,
+ parent: DocumentFileCompat,
+ child: ChildState,
+ ): DocumentFileCompat {
+ return buildChild(
+ context, parent, child.documentId, child.name, child.length,
+ child.lastModified, child.mimeType, child.flags,
+ )
+ }
+
+ private inline fun forEachChildRow(
+ cursor: Cursor,
+ cancellationSignal: CancellationSignal?,
+ strictIds: Boolean,
+ onChild: (
+ documentId: String,
+ name: String,
+ size: Long,
+ lastModified: Long,
+ mimeType: String,
+ flags: Int,
+ ) -> Unit,
+ ) {
+ 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)
+
+ var row = 0
+ while (cursor.moveToNext()) {
+ if ((row++ and CANCELLATION_CHECK_MASK) == 0) {
+ cancellationSignal?.throwIfCanceled()
+ }
+
+ val documentId = cursor.getString(idIndex)
+ if (documentId.isNullOrEmpty()) {
+ check(!strictIds) { "Directory query returned an empty document id" }
+ continue
+ }
+
+ val documentName = getStringOrDefault(cursor, nameIndex)
+ val documentSize = getLongOrDefault(cursor, sizeIndex)
+ val lastModifiedTime = getLongOrDefault(cursor, modifiedIndex, -1L)
+ val documentMimeType = getStringOrDefault(cursor, mimeIndex)
- 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
+ * Default flags to 0 (no capabilities) when not included.
+ * Using `-1` here would make bitwise checks behave as "all flags set".
*/
- 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)
-
- /**
- * 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)
- }
+ val documentFlags = getLongOrDefault(cursor, flagsIndex, 0L).toInt()
+
+ onChild(
+ documentId, documentName, documentSize,
+ lastModifiedTime, documentMimeType, documentFlags
+ )
}
+ cancellationSignal?.throwIfCanceled()
+ }
- return listOfDocuments
+ // Builds the correctly typed document for a child row.
+ private fun buildChild(
+ context: Context, parent: DocumentFileCompat, documentId: String,
+ name: String, size: Long, lastModified: Long, mimeType: String, flags: Int,
+ ): DocumentFileCompat {
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(parent.uri, documentId)
+
+ val childFile: DocumentFileCompat = if (mimeType == Document.MIME_TYPE_DIR) {
+ TreeDocumentFileCompat(context, documentUri, name, size, lastModified, mimeType, flags)
+ } else {
+ SingleDocumentFileCompat(context, documentUri, name, size, lastModified, mimeType, flags)
+ }
+
+ childFile.parentFile = parent
+ return childFile
+ }
+
+ private fun mapCapacity(expectedSize: Int): Int = when {
+ expectedSize < 3 -> expectedSize + 1
+ expectedSize < 1 shl 30 -> expectedSize + expectedSize / 3 + 1
+ else -> Int.MAX_VALUE
}
/**
@@ -214,9 +331,11 @@ internal object ResolverCompat {
}
// Make children uri for query.
- private fun createChildrenUri(uri: Uri): Uri {
+ internal fun createChildrenUri(uri: Uri): Uri {
return DocumentsContract.buildChildDocumentsUriUsingTree(
uri, DocumentsContract.getDocumentId(uri)
)
}
+
+ private const val CANCELLATION_CHECK_MASK = 63
}
\ No newline at end of file