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" /> +