From 339f4952108990d80bc7979d2a1f1f3bce57a3fe Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 31 Aug 2026 16:15:42 +0530 Subject: [PATCH 01/25] feat: add directory observation --- .../dfc/file/DocumentFileCompat.kt | 96 ++++++- .../dfc/observer/DirectoryWatcher.kt | 270 ++++++++++++++++++ .../dfc/observer/SnapshotDiffer.kt | 116 ++++++++ .../lazygeniouz/dfc/observer/WatchSession.kt | 51 ++++ .../dfc/resolver/ResolverCompat.kt | 189 ++++++++---- 5 files changed, 663 insertions(+), 59 deletions(-) create mode 100644 dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryWatcher.kt create mode 100644 dfc/src/main/java/com/lazygeniouz/dfc/observer/SnapshotDiffer.kt create mode 100644 dfc/src/main/java/com/lazygeniouz/dfc/observer/WatchSession.kt 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..550c7d7 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/file/DocumentFileCompat.kt @@ -3,12 +3,21 @@ package com.lazygeniouz.dfc.file import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.os.FileObserver import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import com.lazygeniouz.dfc.controller.DocumentController +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.ALL_EVENTS +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.CREATE +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.DELETE +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.MODIFY +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.MOVED_FROM +import com.lazygeniouz.dfc.file.DocumentFileCompat.Companion.MOVED_TO 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.DirectoryWatcher +import java.io.Closeable import java.io.File /** @@ -195,15 +204,98 @@ 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) } + /** + * Observe the **direct children** of this directory for changes, without polling — + * the SAF equivalent of [FileObserver]. + * + * The returned [Observer] is **not started**; call [Observer.startWatching] to begin & + * [Observer.stopWatching] when done (an unstopped observer leaks a cursor & a thread). + * Existing children emit no events & callbacks run on the observer's worker thread. + * Events are net snapshot changes, so rapid intermediate states can coalesce. Each refresh + * scans all direct children, and providers that return partial/loading cursors are unsupported. + * + * Event constants alias [FileObserver]'s bit values, unsupported bits in the [mask] are + * ignored. A same-id rename emits [MOVED_FROM] (old name) then [MOVED_TO] (new name); an + * id-changing rename surfaces as [DELETE] + [CREATE]. [MODIFY] is best-effort & providers + * that don't publish change notifications cannot be observed. + * + * @param mask Bitwise OR of the events to receive, [ALL_EVENTS] by default. + * @param listener Called with the event & the affected [DocumentFileCompat]. + * + * @throws UnsupportedOperationException if this document is not a SAF backed directory. + * @throws IllegalArgumentException if the [mask] contains no supported event. + */ + fun observe( + mask: Int = ALL_EVENTS, + listener: (event: Int, document: DocumentFileCompat) -> Unit, + ): Observer { + if (this !is TreeDocumentFileCompat || !isDirectory()) { + throw UnsupportedOperationException( + "observe() requires a directory backed by a SAF tree uri." + ) + } + + val effectiveMask = mask and ALL_EVENTS + require(effectiveMask != 0) { "The mask contains no supported event." } + + return Observer(DirectoryWatcher(this, effectiveMask, listener)) + } + + /** + * A directory observation created via [observe]. Start/stop operations are idempotent and + * thread-safe, including from inside callbacks. Also a [Closeable] ([close] == [stopWatching]). + */ + class Observer internal constructor(private val watcher: DirectoryWatcher) : Closeable { + + /** + * Starts a new observation session. + * + * [onReady] runs once the initial snapshot is installed; mutations made after it begins + * are observable. [onError] runs only when that session stops because of a terminal + * initialization or permission failure. Both callbacks run on the observer worker. + * Calls made while already started are ignored, including their callbacks. + */ + fun startWatching( + onError: (Throwable) -> Unit = {}, + onReady: () -> Unit = {}, + ) = watcher.startWatching(onError, onReady) + + /** + * Invalidates the session and prevents further event callbacks before returning. Cleanup + * then completes on the worker after any provider operation already in flight returns. + */ + fun stopWatching() = watcher.stopWatching() + + override fun close() = stopWatching() + } + companion object { + /** Same as [FileObserver.MODIFY]: a child's contents / metadata changed. */ + const val MODIFY = FileObserver.MODIFY + + /** Same as [FileObserver.MOVED_FROM]: a child was renamed, carries the **old** name. */ + const val MOVED_FROM = FileObserver.MOVED_FROM + + /** Same as [FileObserver.MOVED_TO]: a child was renamed, carries the **new** name. */ + 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 [observe] can produce (unlike [FileObserver.ALL_EVENTS]). */ + const val ALL_EVENTS = MODIFY or MOVED_FROM or MOVED_TO or CREATE or DELETE + /** * Build a Document Tree with this helper. * @@ -254,4 +346,4 @@ abstract class DocumentFileCompat( return paths.size >= 2 && "tree" == paths[0] } } -} \ No newline at end of file +} diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryWatcher.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryWatcher.kt new file mode 100644 index 0000000..3f4fbf4 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/DirectoryWatcher.kt @@ -0,0 +1,270 @@ +package com.lazygeniouz.dfc.observer + +import android.database.ContentObserver +import android.database.Cursor +import android.os.Looper +import android.os.OperationCanceledException +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.logger.ErrorLogger +import com.lazygeniouz.dfc.resolver.ResolverCompat + +/** + * Event driven watcher for the **direct children** of a SAF directory, zero polling. + * + * AOSP refcounts its internal `FileObserver` against **active** directory cursors, so the + * query [Cursor] stays alive for the lifetime of the watch & a replacement cursor is observed + * **before** the previous one closes (no watch gap). + * + * 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, + 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 + + 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) 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 + synchronized(lock) { + stopped = session ?: return + session = null + } + stopped.cancellationSignal.cancel() + // An admitted callback finishes before stop returns; future ones see a stale session. + synchronized(stopped.callbackLock) {} + stopped.handler.post { teardown(stopped) } + } + + private fun scheduleRefresh(session: WatchSession) { + if (!session.isCurrent) return + if (session.refreshScheduled.compareAndSet(false, true)) { + session.handler.post { performRefresh(session) } + } + } + + // Initial query: baseline snapshot, no events emitted for pre-existing children. + 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) { + val cursor = query(session) + ?: throw IllegalStateException("The provider returned no directory cursor") + + var attached = false + try { + cursor.registerContentObserver(session.observer) + val baseline = readSnapshot(session, cursor) + session.cancellationSignal.throwIfCanceled() + if (!session.isCurrent) return + + session.cursor = cursor + session.snapshot = baseline + attached = true + + // A second snapshot closes the pre-registration blind window before readiness. + val startupEvents = reconcileBeforeReady(session) + if (!session.isCurrent) return + emitReady(session) + for (event in startupEvents) { + if (!emit(session, event)) return + } + } finally { + if (!attached) release(cursor, session.observer) + } + } + + private fun performRefresh(session: WatchSession) { + session.refreshScheduled.set(false) + if (!session.isCurrent) return + try { + refreshOnce(session) + } catch (cancelled: OperationCanceledException) { + if (session.isCurrent) { + ErrorLogger.logError("Directory refresh was cancelled, keeping the watch", cancelled) + } + } catch (security: SecurityException) { + ErrorLogger.logError("Permission revoked, releasing the observer", security) + stopSelf(session, security) + } catch (exception: Exception) { + ErrorLogger.logError("Directory refresh failed, keeping the existing watch", exception) + } + } + + private fun reconcileBeforeReady(session: WatchSession): List { + return try { + refreshOnce(session, deliverEvents = false) + } catch (cancelled: OperationCanceledException) { + throw cancelled + } catch (security: SecurityException) { + throw security + } catch (exception: Exception) { + // The initial cursor is already active; a transient reconciliation failure is safe. + ErrorLogger.logError("Initial reconciliation failed, keeping the existing watch", exception) + emptyList() + } + } + + // Re-query, diff & swap cursors without a watch gap. Exceptions leave the old watch intact. + private fun refreshOnce( + session: WatchSession, + deliverEvents: Boolean = true, + ): List { + val previousCursor = session.cursor ?: return emptyList() + + val freshCursor = query(session) + ?: throw IllegalStateException("The provider returned no directory cursor") + + var promoted = false + try { + // Observe the fresh cursor BEFORE reading it & BEFORE closing the previous one. + freshCursor.registerContentObserver(session.observer) + val freshSnapshot = readSnapshot(session, freshCursor) + session.cancellationSignal.throwIfCanceled() + if (!session.isCurrent) return emptyList() + + val events = SnapshotDiffer.diff( + session.snapshot, freshSnapshot, mask, session.cancellationSignal + ) + session.cancellationSignal.throwIfCanceled() + if (!session.isCurrent) return emptyList() + + session.cursor = freshCursor + session.snapshot = freshSnapshot + promoted = true + release(previousCursor, session.observer) + + if (deliverEvents) { + for (diffEvent in events) { + if (!emit(session, diffEvent)) break + } + } + return events + } finally { + if (!promoted) release(freshCursor, session.observer) + } + } + + private fun query(session: WatchSession): Cursor? { + return directory.context.contentResolver.query( + ResolverCompat.createChildrenUri(directory.uri), + ResolverCompat.fullProjection, + null, null, null, + session.cancellationSignal, + ) + } + + private fun emit(session: WatchSession, diffEvent: SnapshotDiffer.DiffEvent): Boolean { + return synchronized(session.callbackLock) { + if (!session.isCurrent) return@synchronized false + try { + listener(diffEvent.event, diffEvent.document) + } catch (exception: Exception) { + ErrorLogger.logError("Observer listener threw an exception", exception) + } + session.isCurrent + } + } + + private fun readSnapshot( + session: WatchSession, + cursor: Cursor, + ): LinkedHashMap { + return ResolverCompat.readChildSnapshot( + directory.context, + cursor, + directory, + session.snapshot, + session.cancellationSignal, + ) + } + + 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) + } + } + } + + // 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 + } + session.cancellationSignal.cancel() + teardown(session) + synchronized(session.callbackLock) { + try { + session.onError(failure) + } catch (exception: Exception) { + ErrorLogger.logError("Observer error callback threw an exception", exception) + } + } + } + + // Runs on the session's worker, always last: releases its resources & quits the looper. + private fun teardown(session: WatchSession) { + val cursor = session.cursor + session.cursor = null + session.snapshot = LinkedHashMap() + + if (cursor != null) release(cursor, session.observer) + + Looper.myLooper()?.quitSafely() + } + + // The single, exception-safe cursor cleanup: unregister + close, never throws. + private fun release(cursor: Cursor, observer: ContentObserver) { + try { + cursor.unregisterContentObserver(observer) + } catch (_: Exception) { + } + try { + cursor.close() + } catch (_: Exception) { + } + } +} \ No newline at end of file diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/SnapshotDiffer.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/SnapshotDiffer.kt new file mode 100644 index 0000000..7700d34 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/SnapshotDiffer.kt @@ -0,0 +1,116 @@ +package com.lazygeniouz.dfc.observer + +import android.os.CancellationSignal +import com.lazygeniouz.dfc.file.DocumentFileCompat + +/** + * 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 { + + /** + * A single derived event; [document] is the **old** child for DELETE & MOVED_FROM + * (last known metadata), the fresh one otherwise. + */ + internal class DiffEvent(val event: Int, val document: DocumentFileCompat) + + /** Diff [old] against [new]; empty list when nothing changed. */ + internal fun diff( + old: Map, + new: Map, + mask: Int = DocumentFileCompat.ALL_EVENTS, + cancellationSignal: CancellationSignal? = null, + ): List { + if (old.isEmpty() && new.isEmpty()) return emptyList() + + val events = ArrayList() + if (mask includes DocumentFileCompat.DELETE) { + collectDeletions(old, new, events, cancellationSignal) + } + if (mask and CHANGE_EVENTS != 0) { + collectChanges(old, new, mask, events, cancellationSignal) + } + if (mask includes DocumentFileCompat.CREATE) { + collectCreations(old, new, events, cancellationSignal) + } + cancellationSignal?.throwIfCanceled() + return events + } + + // In old snapshot order. + private fun collectDeletions( + old: Map, + new: Map, + events: MutableList, + cancellationSignal: CancellationSignal?, + ) { + var row = 0 + for ((documentId, oldChild) in old) { + if ((row++ and CANCELLATION_CHECK_MASK) == 0) cancellationSignal?.throwIfCanceled() + if (documentId !in new) { + events.add(DiffEvent(DocumentFileCompat.DELETE, oldChild)) + } + } + } + + // Renames & modifications, in old snapshot order. + private fun collectChanges( + old: Map, + new: Map, + mask: Int, + events: MutableList, + cancellationSignal: CancellationSignal?, + ) { + var row = 0 + for ((documentId, oldChild) in old) { + if ((row++ and CANCELLATION_CHECK_MASK) == 0) cancellationSignal?.throwIfCanceled() + val newChild = new[documentId] ?: continue + + // Reused instance == unchanged row (snapshot reads reuse only identical fields). + if (oldChild === newChild) continue + + if (oldChild.name != newChild.name) { + if (mask includes DocumentFileCompat.MOVED_FROM) { + events.add(DiffEvent(DocumentFileCompat.MOVED_FROM, oldChild)) + } + if (mask includes DocumentFileCompat.MOVED_TO) { + events.add(DiffEvent(DocumentFileCompat.MOVED_TO, newChild)) + } + } else if ( + mask includes DocumentFileCompat.MODIFY && metadataChanged(oldChild, newChild) + ) { + events.add(DiffEvent(DocumentFileCompat.MODIFY, newChild)) + } + } + } + + // In new snapshot order. + private fun collectCreations( + old: Map, + new: Map, + events: MutableList, + cancellationSignal: CancellationSignal?, + ) { + var row = 0 + for ((documentId, newChild) in new) { + if ((row++ and CANCELLATION_CHECK_MASK) == 0) cancellationSignal?.throwIfCanceled() + if (documentId !in old) { + events.add(DiffEvent(DocumentFileCompat.CREATE, newChild)) + } + } + } + + // Flags intentionally excluded, see the class KDoc. + private fun metadataChanged(old: DocumentFileCompat, new: DocumentFileCompat): Boolean { + return old.length != new.length + || old.lastModified != new.lastModified + || old.documentMimeType != new.documentMimeType + } + + private infix fun Int.includes(event: Int): Boolean = and(event) != 0 + + private const val CHANGE_EVENTS = + DocumentFileCompat.MOVED_FROM or DocumentFileCompat.MOVED_TO or DocumentFileCompat.MODIFY + private const val CANCELLATION_CHECK_MASK = 63 +} \ No newline at end of file diff --git a/dfc/src/main/java/com/lazygeniouz/dfc/observer/WatchSession.kt b/dfc/src/main/java/com/lazygeniouz/dfc/observer/WatchSession.kt new file mode 100644 index 0000000..22f3d08 --- /dev/null +++ b/dfc/src/main/java/com/lazygeniouz/dfc/observer/WatchSession.kt @@ -0,0 +1,51 @@ +package com.lazygeniouz.dfc.observer + +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.file.DocumentFileCompat +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() + + // 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 cursor: Cursor? = null + var snapshot: LinkedHashMap = LinkedHashMap() + + 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..49eb3e9 100644 --- a/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt +++ b/dfc/src/main/java/com/lazygeniouz/dfc/resolver/ResolverCompat.kt @@ -4,6 +4,7 @@ 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 @@ -122,9 +123,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 +132,138 @@ 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) + + forEachChild(context, cursor, parent, emptyMap(), null, strictIds = false) { _, child -> + listOfDocuments.add(child) + } + return listOfDocuments + } + + /** + * Reads an observer snapshot directly into a map keyed by document id. Unchanged rows reuse + * their previous [DocumentFileCompat] instance; malformed or duplicate ids reject the entire + * scan so malformed rows cannot be interpreted as deletions. + */ + internal fun readChildSnapshot( + context: Context, + cursor: Cursor, + parent: DocumentFileCompat, + reusable: Map, + cancellationSignal: CancellationSignal, + ): LinkedHashMap { + val snapshot = LinkedHashMap(mapCapacity(cursor.count)) + forEachChild( + context, cursor, parent, reusable, cancellationSignal, strictIds = true + ) { documentId, child -> + check(snapshot.put(documentId, child) == null) { + "Directory query returned duplicate document id: $documentId" + } + } + return snapshot + } + + private inline fun forEachChild( + context: Context, + cursor: Cursor, + parent: DocumentFileCompat, + reusable: Map, + cancellationSignal: CancellationSignal?, + strictIds: Boolean, + onChild: (documentId: String, child: DocumentFileCompat) -> 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() + + val previous = reusable[documentId] + val child = if (previous != null && canReuse( + previous, documentName, documentSize, + lastModifiedTime, documentMimeType, documentFlags + ) + ) { + previous + } else { + buildChild( + context, parent, documentId, documentName, + documentSize, lastModifiedTime, documentMimeType, documentFlags + ) } + onChild(documentId, child) + } + cancellationSignal?.throwIfCanceled() + } + + // Identical fields (flags included: capabilities may change without a diff event). + private fun canReuse( + previous: DocumentFileCompat, + name: String, size: Long, lastModified: Long, mimeType: String, flags: Int, + ): Boolean { + return previous.name == name + && previous.length == size + && previous.lastModified == lastModified + && previous.documentMimeType == mimeType + && previous.documentFlags == flags + } + + // 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) } - return listOfDocuments + 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 +287,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) ) } -} \ No newline at end of file + + private const val CANCELLATION_CHECK_MASK = 63 +} From 035702898727fc46c1e2468b2a0a8f9cb7c99ab0 Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 31 Aug 2026 16:15:58 +0530 Subject: [PATCH 02/25] test: cover directory observation --- dfc/build.gradle | 7 + dfc/src/androidTest/AndroidManifest.xml | 14 + .../dfc/observer/DirectoryObserveTest.kt | 355 +++++++++++++++++ .../dfc/observer/ObserverResilienceTest.kt | 356 ++++++++++++++++++ .../dfc/observer/SnapshotDifferTest.kt | 277 ++++++++++++++ .../dfc/observer/TestDocumentsProvider.kt | 179 +++++++++ .../dfc/resolver/ReadChildrenReuseTest.kt | 167 ++++++++ 7 files changed, 1355 insertions(+) create mode 100644 dfc/src/androidTest/AndroidManifest.xml create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/DirectoryObserveTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/ObserverResilienceTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/SnapshotDifferTest.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/TestDocumentsProvider.kt create mode 100644 dfc/src/androidTest/java/com/lazygeniouz/dfc/resolver/ReadChildrenReuseTest.kt diff --git a/dfc/build.gradle b/dfc/build.gradle index 6aca964..169cb4f 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,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + 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..a9a55cf --- /dev/null +++ b/dfc/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + 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..9a44fda --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/DirectoryObserveTest.kt @@ -0,0 +1,355 @@ +package com.lazygeniouz.dfc.observer + +import android.content.Context +import android.net.Uri +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: DocumentFileCompat.Observer? = 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(mask: Int = DocumentFileCompat.ALL_EVENTS): DocumentFileCompat.Observer { + 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: DocumentFileCompat.Observer) { + 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 = System.currentTimeMillis() + timeoutMs + while (observerThreadAlive()) { + if (System.currentTimeMillis() >= deadline) fail("Observer worker thread was not released") + Thread.sleep(25) + } + } + + private fun awaitAllChildCursorsClosed(timeoutMs: Long = 5000) { + val deadline = System.currentTimeMillis() + timeoutMs + while (true) { + val opened = TestDocumentsProvider.openChildCursors.get() - openedCursorBaseline + val closed = TestDocumentsProvider.closedChildCursors.get() - closedCursorBaseline + if (opened == closed) return + if (System.currentTimeMillis() >= deadline) { + fail("Leaked child cursors: opened $opened, closed $closed") + } + Thread.sleep(25) + } + } + + // 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(DocumentFileCompat.CREATE to "c.txt", awaitEvent()) + } + + @Test + fun externalDelete_emitsDelete() { + val victim = createFile("victim.txt") + + startAndAwaitWatching(observe()) + + victim.delete() + notifyChildren() + + awaitEventFor("victim.txt", DocumentFileCompat.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", DocumentFileCompat.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(DocumentFileCompat.DELETE to "old-name.txt", awaitEvent()) + assertEquals(DocumentFileCompat.CREATE to "new-name.txt", awaitEvent()) + } + + @Test + fun maskFiltering_suppressesUnrequestedEvents() { + val victim = createFile("victim.txt") + val deleteOnly = observe(DocumentFileCompat.DELETE) + startAndAwaitWatching(deleteOnly) + + createFile("noise.txt") // CREATE: must be filtered out + victim.delete() + notifyChildren() + + assertEquals(DocumentFileCompat.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(DocumentFileCompat.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(DocumentFileCompat.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 restartAfterStop_deliversEventsAgain() { + startAndAwaitWatching(observe()) + observer?.stopWatching() + + startAndAwaitWatching(observer!!) + + createFile("again.txt") + notifyChildren() + + awaitEventFor("again.txt", DocumentFileCompat.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: DocumentFileCompat.Observer + 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 + fun observe_withNoSupportedEventBits_throws() { + assertThrows(IllegalArgumentException::class.java) { + directory.observe(0) { _, _ -> } + } + // Only unsupported FileObserver bits: masked to zero, equally useless. + assertThrows(IllegalArgumentException::class.java) { + directory.observe(0x20 /* OPEN */) { _, _ -> } + } + } +} 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..cca4eed --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/ObserverResilienceTest.kt @@ -0,0 +1,356 @@ +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.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +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: DocumentFileCompat.Observer? = 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(): DocumentFileCompat.Observer { + 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: DocumentFileCompat.Observer) { + 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: DocumentFileCompat.Observer): 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) { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (TestDocumentsProvider.childQueryCount.get() - baseline < expected) { + if (SystemClock.elapsedRealtime() >= deadline) fail("Expected $expected refresh queries") + 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(DocumentFileCompat.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 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) + assertNull(events.poll(250, TimeUnit.MILLISECONDS)) + + TestDocumentsProvider.failChildQueries = false + createFile("caught.txt") + notifyChildren() + + assertEquals( + setOf( + DocumentFileCompat.CREATE to "missed.txt", + DocumentFileCompat.CREATE to "caught.txt", + ), + setOf(requireEvent(), requireEvent()) + ) + } + + @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( + DocumentFileCompat.CREATE to "b1.txt", + DocumentFileCompat.CREATE to "b2.txt", + DocumentFileCompat.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( + DocumentFileCompat.CREATE to "m1.txt", + DocumentFileCompat.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(DocumentFileCompat.CREATE to "recovered-materialization.txt", requireEvent()) + } + + @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 workerThread_isReleasedAfterStop() { + startAndAwaitWatching(observe()) + assertTrue(observerThreadAlive()) + + observer?.stopWatching() + awaitObserverThreadGone() + awaitAllChildCursorsClosed() + } +} diff --git a/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/SnapshotDifferTest.kt b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/SnapshotDifferTest.kt new file mode 100644 index 0000000..74a4970 --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/SnapshotDifferTest.kt @@ -0,0 +1,277 @@ +package com.lazygeniouz.dfc.observer + +import android.content.Context +import android.net.Uri +import android.os.CancellationSignal +import android.os.OperationCanceledException +import android.provider.DocumentsContract +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat +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 val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + private fun child( + id: String, + name: String = "$id.txt", + mime: String = "text/plain", + size: Long = 10L, + lastModified: Long = 100L, + flags: Int = 0, + ): DocumentFileCompat { + val uri = Uri.parse("content://com.test.provider/tree/root/document/$id") + val document = if (mime == "vnd.android.document/directory") { + TreeDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } else { + SingleDocumentFileCompat(context, uri, name, size, lastModified, mime, flags) + } + return document + } + + private fun snapshotOf(vararg children: DocumentFileCompat): LinkedHashMap { + val map = LinkedHashMap() + children.forEach { map[DocumentsContract.getDocumentId(it.uri)] = it } + return map + } + + private fun events(vararg children: Pair) = children.toList() + + private fun List.simplified() = + map { it.event to DocumentsContract.getDocumentId(it.document.uri) } + + // 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(DocumentFileCompat.CREATE to "b"), result.simplified()) + } + + @Test + fun emptyOldToPopulated_emitsCreateForEverything() { + val result = SnapshotDiffer.diff(LinkedHashMap(), snapshotOf(child("a"), child("b"))) + assertEquals( + events(DocumentFileCompat.CREATE to "a", DocumentFileCompat.CREATE to "b"), + result.simplified() + ) + } + + @Test + fun missingId_emitsDeleteWithLastKnownDocument() { + val result = SnapshotDiffer.diff(snapshotOf(child("a"), child("b")), snapshotOf(child("a"))) + assertEquals(events(DocumentFileCompat.DELETE to "b"), result.simplified()) + assertEquals("b.txt", result.single().document.name) + } + + @Test + fun sizeChange_emitsModify() { + val result = SnapshotDiffer.diff( + snapshotOf(child("a", size = 10L)), + snapshotOf(child("a", size = 20L)), + ) + assertEquals(events(DocumentFileCompat.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(DocumentFileCompat.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(DocumentFileCompat.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(DocumentFileCompat.MOVED_FROM to "a", DocumentFileCompat.MOVED_TO to "a"), + result.simplified() + ) + assertEquals("old.txt", result[0].document.name) + assertEquals("new.txt", result[1].document.name) + } + + @Test + fun renamePlusMetadataChange_emitsOnlyTheMovePair() { + 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(DocumentFileCompat.MOVED_FROM to "a", DocumentFileCompat.MOVED_TO 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(DocumentFileCompat.DELETE to "a", DocumentFileCompat.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( + DocumentFileCompat.DELETE to "gone1", + DocumentFileCompat.DELETE to "gone2", + DocumentFileCompat.MOVED_FROM to "renamed", + DocumentFileCompat.MOVED_TO to "renamed", + DocumentFileCompat.MODIFY to "changed", + DocumentFileCompat.CREATE to "fresh1", + DocumentFileCompat.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(DocumentFileCompat.DELETE to "gone", DocumentFileCompat.CREATE to "fresh"), + SnapshotDiffer.diff( + old, new, DocumentFileCompat.CREATE or DocumentFileCompat.DELETE + ).simplified() + ) + + assertEquals( + events(DocumentFileCompat.MODIFY to "changed"), + SnapshotDiffer.diff(old, new, DocumentFileCompat.MODIFY).simplified() + ) + + // The move pair can be filtered to either half individually. + assertEquals( + events(DocumentFileCompat.MOVED_FROM to "renamed"), + SnapshotDiffer.diff(old, new, DocumentFileCompat.MOVED_FROM).simplified() + ) + assertEquals( + events(DocumentFileCompat.MOVED_TO to "renamed"), + SnapshotDiffer.diff(old, new, DocumentFileCompat.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, DocumentFileCompat.MODIFY) + assertEquals(0x00000040, DocumentFileCompat.MOVED_FROM) + assertEquals(0x00000080, DocumentFileCompat.MOVED_TO) + assertEquals(0x00000100, DocumentFileCompat.CREATE) + assertEquals(0x00000200, DocumentFileCompat.DELETE) + } + + // endregion +} 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..fc1f755 --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/observer/TestDocumentsProvider.kt @@ -0,0 +1,179 @@ +package com.lazygeniouz.dfc.observer + +import android.database.ContentObserver +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +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.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).apply { mkdirs() } + + 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 { + return MatrixCursor(resolve(projection)).also { cursor -> + include(cursor, documentId, fileFor(documentId)) + } + } + + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array?, + sortOrder: String?, + ): Cursor { + childQueryCount.incrementAndGet() + childQueryGate?.await(10, TimeUnit.SECONDS) + if (revokePermissions) throw SecurityException("Permission revoked (test)") + if (failChildQueries) { + failedChildQueries.incrementAndGet() + throw IllegalStateException("Transient failure (test)") + } + + val cursor = TrackingCursor(resolve(projection)) + openChildCursors.incrementAndGet() + fileFor(parentDocumentId).listFiles()?.sortedBy { it.name }?.forEach { child -> + include(cursor, "$parentDocumentId/${child.name}", child) + } + cursor.setNotificationUri( + context!!.contentResolver, childrenUriOf(parentDocumentId) + ) + childSnapshotCaptured?.countDown() + childQueryReturnGate?.await(10, TimeUnit.SECONDS) + return cursor + } + + /** Child cursor with failure injection and close accounting. */ + private class TrackingCursor(columns: Array) : MatrixCursor(columns) { + + private val closedOnce = java.util.concurrent.atomic.AtomicBoolean(false) + + 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() { + if (closedOnce.compareAndSet(false, true)) closedChildCursors.incrementAndGet() + 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 revokePermissions = false + + @Volatile + var revokeDuringMaterialization = false + + @Volatile + var failObserverRegistration = false + + @Volatile + var childQueryGate: CountDownLatch? = null + + @Volatile + var childSnapshotCaptured: CountDownLatch? = null + + @Volatile + var childQueryReturnGate: CountDownLatch? = null + + val childQueryCount = AtomicInteger(0) + val failedChildQueries = AtomicInteger(0) + val openChildCursors = AtomicInteger(0) + val closedChildCursors = AtomicInteger(0) + + fun resetTestControls() { + failChildQueries = false + revokePermissions = false + revokeDuringMaterialization = false + failObserverRegistration = false + childQueryGate?.countDown() + childQueryGate = null + childQueryReturnGate?.countDown() + childQueryReturnGate = null + childSnapshotCaptured = null + } + + fun childrenUriOf(parentDocumentId: String) = + DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId)!! + } +} 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..0435d29 --- /dev/null +++ b/dfc/src/androidTest/java/com/lazygeniouz/dfc/resolver/ReadChildrenReuseTest.kt @@ -0,0 +1,167 @@ +package com.lazygeniouz.dfc.resolver + +import android.content.Context +import android.database.MatrixCursor +import android.net.Uri +import android.os.CancellationSignal +import android.os.OperationCanceledException +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lazygeniouz.dfc.file.DocumentFileCompat +import com.lazygeniouz.dfc.file.internals.SingleDocumentFileCompat +import com.lazygeniouz.dfc.file.internals.TreeDocumentFileCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +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 val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + private val parent = TreeDocumentFileCompat( + context, Uri.parse("content://com.test.provider/tree/root/document/root"), + "root", 0, 0, Document.MIME_TYPE_DIR, 0 + ) + + 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(), + ): Map { + return cursor.use { + ResolverCompat.readChildSnapshot( + context, it, parent, reusable, cancellationSignal + ) + } + } + + @Test + fun plainRead_keysByDocumentIdAndSetsParent() { + val children = read(cursorOf(row("a"), row("b", mime = Document.MIME_TYPE_DIR))) + + assertEquals(2, children.size) + assertEquals("a", DocumentsContract.getDocumentId(children.getValue("a").uri)) + assertSame(parent, children.getValue("a").parentFile) + assertTrue(children.getValue("a") is SingleDocumentFileCompat) + assertTrue(children.getValue("b") is TreeDocumentFileCompat) + } + + @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_buildsFreshInstanceOfCorrectType() { + 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")) + assertTrue(first.getValue("a") is SingleDocumentFileCompat) + assertTrue(second.getValue("a") is TreeDocumentFileCompat) + } + + @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 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) + } + } +} From 0e0abb4f9b8b8b662fd80a94c4e4d4ec2f6b331f Mon Sep 17 00:00:00 2001 From: Darshan Date: Mon, 31 Aug 2026 16:16:14 +0530 Subject: [PATCH 03/25] feat: add observer sample --- app/src/main/AndroidManifest.xml | 6 + .../filecompat/example/MainActivity.kt | 12 + .../filecompat/example/ObserverActivity.kt | 421 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 9 + app/src/main/res/layout/activity_observer.xml | 151 +++++++ app/src/main/res/values/strings.xml | 35 +- 6 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/lazygeniouz/filecompat/example/ObserverActivity.kt create mode 100644 app/src/main/res/layout/activity_observer.xml 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: DocumentFileCompat.Observer? = 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) + 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) + 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) + 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) { + DocumentFileCompat.CREATE -> "CREATE" + DocumentFileCompat.DELETE -> "DELETE" + DocumentFileCompat.MODIFY -> "MODIFY" + DocumentFileCompat.MOVED_FROM -> "MOVED_FROM" + DocumentFileCompat.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 + } +} 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" /> +