gtauri: implement 100% pure-push FileObserver in Kotlin (sub-50ms instant add/delete)

This commit is contained in:
Greg Pomerantz 2026-11-03 11:02:40 -05:00
parent 471d2faeab
commit 314022cfb2
2 changed files with 69 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import android.content.ContentUris
import android.database.ContentObserver import android.database.ContentObserver
import android.net.Uri import android.net.Uri
import android.os.FileObserver
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@ -17,6 +18,7 @@ import java.util.concurrent.Executors
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private var observer: ContentObserver? = null private var observer: ContentObserver? = null
private val fileObservers = mutableListOf<FileObserver>()
// MediaStore event path: the OS tells us WHICH URI changed; we query // MediaStore event path: the OS tells us WHICH URI changed; we query
// that one row (~ms) and hand the result to Rust via a payload file + // that one row (~ms) and hand the result to Rust via a payload file +
@ -77,6 +79,50 @@ class MainActivity : TauriActivity() {
} }
} }
private fun startFileObservers() {
val roots = listOf(
android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_PICTURES),
android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DCIM)
)
for (root in roots) {
if (root != null && root.exists()) {
val dirs = mutableListOf(root)
root.listFiles()?.forEach { if (it.isDirectory) dirs.add(it) }
for (dir in dirs) {
try {
val observer = object : FileObserver(dir.absolutePath, CLOSE_WRITE or DELETE or MOVED_TO or MOVED_FROM) {
override fun onEvent(event: Int, path: String?) {
if (path.isNullOrEmpty()) return
if (path.startsWith(".")) return
val fullPath = java.io.File(dir, path).absolutePath
val ext = fullPath.substringAfterLast('.', "").lowercase()
val imageExts = setOf("jpg", "jpeg", "png", "webp", "heic", "heif", "bmp")
if (!imageExts.contains(ext)) return
fsExecutor.execute {
when (event and FileObserver.ALL_EVENTS) {
FileObserver.CLOSE_WRITE, FileObserver.MOVED_TO -> {
val f = java.io.File(fullPath)
if (f.exists() && f.length() > 0) {
deliver("A|0|$fullPath|${f.length()}")
}
}
FileObserver.DELETE, FileObserver.MOVED_FROM -> {
deliver("D_PATH|$fullPath")
}
}
}
}
}
observer.startWatching()
fileObservers.add(observer)
} catch (e: Exception) {
android.util.Log.w("gallery-ms", "FileObserver error: " + e)
}
}
}
}
}
/** Handle ONE changed URI: query that exact MediaStore row. /** Handle ONE changed URI: query that exact MediaStore row.
* Row exists → A (added/modified). Row gone → D (deleted). */ * Row exists → A (added/modified). Row gone → D (deleted). */
private fun handleUri(uri: Uri?) { private fun handleUri(uri: Uri?) {
@ -180,9 +226,12 @@ class MainActivity : TauriActivity() {
// the observer won't replay — push whatever was indexed recently. // the observer won't replay — push whatever was indexed recently.
mainHandler.postDelayed({ fsExecutor.execute { windowQuery() } }, 1500) mainHandler.postDelayed({ fsExecutor.execute { windowQuery() } }, 1500)
requestMediaPermission() requestMediaPermission()
startFileObservers()
} }
override fun onDestroy() { override fun onDestroy() {
fileObservers.forEach { it.stopWatching() }
fileObservers.clear()
android.util.Log.i("gallery-ms", "onDestroy: unregistering observer") android.util.Log.i("gallery-ms", "onDestroy: unregistering observer")
observer?.let { contentResolver.unregisterContentObserver(it) } observer?.let { contentResolver.unregisterContentObserver(it) }
fsExecutor.shutdownNow() fsExecutor.shutdownNow()

View File

@ -1068,6 +1068,25 @@ fn mediastore_apply(app: &tauri::AppHandle, text: &str) -> bool {
let _ = db::unmap_media(&mut conn, id); let _ = db::unmap_media(&mut conn, id);
} }
} }
"D_PATH" => {
let Some(path) = f.next() else { continue };
if let Some(rid) = conn
.query_row(
"SELECT rowid FROM items WHERE path = ?1",
rusqlite::params![path],
|r| r.get(0),
)
.ok()
{
FAILS.lock().unwrap().remove(&rid);
}
if let Ok(n) = db::delete_by_path(&mut conn, path) {
removed += n;
if n > 0 {
tr(&format!("mediastore del path={path}"));
}
}
}
_ => {} _ => {}
} }
} }
@ -1230,7 +1249,7 @@ fn watch_files(app: tauri::AppHandle) {
let mut last_full = std::time::Instant::now(); let mut last_full = std::time::Instant::now();
loop { loop {
let woke = match rx.recv_timeout(std::time::Duration::from_secs(1)) { let woke = match rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(e) => Some(e), Ok(e) => Some(e),
Err(mpsc::RecvTimeoutError::Timeout) => None, Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Disconnected) => break,