package com.syncflow.worker import android.app.* import android.content.Context import android.content.Intent import android.database.ContentObserver import android.net.Uri import android.os.Build import android.os.FileObserver import android.os.Handler import android.os.IBinder import android.os.Looper import android.provider.DocumentsContract import androidx.core.app.NotificationCompat import androidx.work.ExistingWorkPolicy import androidx.work.WorkInfo import androidx.work.WorkManager import kotlinx.coroutines.flow.first import com.syncflow.MainActivity import com.syncflow.R import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.toDomain import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.sync.LocalAccessor import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.io.File import javax.inject.Inject @AndroidEntryPoint class FileWatchService : Service() { @Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var fileStateDao: SyncFileStateDao private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val mainHandler = Handler(Looper.getMainLooper()) // Prevents concurrent refresh() calls from doubling watchers + catchup scans private val refreshMutex = Mutex() // Multiple FileObserver instances per pair: one per directory (recursive) private val fileObservers = mutableMapOf>() private val contentObservers = mutableMapOf() private val debounceJobs = mutableMapOf() // Persistent monitors that watch WorkManager for ANY sync (manual, catchup, onchange) // so the cooldown is set regardless of who triggered the sync. private val syncMonitorJobs = mutableMapOf() // After a sync completes, suppress FileObserver events for this long. private val syncCooldownUntil = mutableMapOf() companion object { const val CHANNEL_WATCH = "sync_watching" private const val NOTIFICATION_ID = 1002 fun start(context: Context) { val intent = Intent(context, FileWatchService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { context.startService(intent) } } fun stop(context: Context) { context.stopService(Intent(context, FileWatchService::class.java)) } } override fun onCreate() { super.onCreate() ensureChannel() startForeground(NOTIFICATION_ID, buildNotification(0)) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { scope.launch { refresh() } return START_STICKY } override fun onDestroy() { clearWatchers() scope.cancel() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null private suspend fun refresh() = refreshMutex.withLock { clearWatchers() val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE } pairs.forEach { pair -> val pairId = pair.id val localPath = pair.localPath if (localPath.startsWith("content://")) { // Try to resolve the SAF tree URI to a real filesystem path so we can use // FileObserver. ContentObserver on a DocumentsProvider tree URI only fires // when changes come through the SAF API, not for raw filesystem writes. val realPath = safTreeUriToRealPath(localPath) if (realPath != null) { watchPath(realPath, pairId, pair.wifiOnly, pair.chargingOnly) } else { // Fallback: register a ContentObserver for SAF paths that can't be resolved val treeUri = Uri.parse(localPath) val observer = object : ContentObserver(mainHandler) { override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) } contentResolver.registerContentObserver(treeUri, true, observer) contentObservers[pairId] = observer Timber.d("FileWatchService: watching SAF URI (ContentObserver fallback) for pair $pairId") } } else { watchPath(localPath, pairId, pair.wifiOnly, pair.chargingOnly) } } val count = fileObservers.keys.size + contentObservers.size updateNotification(count) if (count == 0) { Timber.d("FileWatchService: no ON_CHANGE pairs, stopping") stopSelf() } } private fun safTreeUriToRealPath(uriString: String): String? { return try { val treeUri = Uri.parse(uriString) val docId = DocumentsContract.getTreeDocumentId(treeUri) // docId format is "primary:RelativePath" for primary internal storage if (docId.startsWith("primary:")) { val relative = docId.removePrefix("primary:") "/storage/emulated/0/$relative" } else { null } } catch (e: Exception) { Timber.w("FileWatchService: could not resolve SAF URI to real path: $e") null } } private fun watchPath(path: String, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { val dir = File(path) if (!dir.exists()) { Timber.w("FileWatchService: path does not exist for pair $pairId: $path") return } fileObservers[pairId] = mutableListOf() // Set startup cooldown BEFORE registering watchers so inotify events that fire // immediately on registration don't trigger the debounce before catchupScan runs. syncCooldownUntil[pairId] = System.currentTimeMillis() + 15_000 watchDirRecursive(dir, pairId, wifiOnly, chargingOnly) Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)") startSyncMonitor(pairId) scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) } } // Watches WorkManager for ANY sync tagged sync_$pairId (manual, catchup, onchange). // Sets cooldown while running and for 60s after, so FileObserver events from our // own file writes never trigger a re-sync regardless of what started the sync. private fun startSyncMonitor(pairId: Long) { syncMonitorJobs[pairId]?.cancel() syncMonitorJobs[pairId] = scope.launch { var wasSyncing = false WorkManager.getInstance(applicationContext) .getWorkInfosByTagFlow("sync_$pairId") .collect { infos -> val isSyncing = infos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } if (isSyncing) { Timber.d("FileWatchService: sync active for pair $pairId — cooldown extended") syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000 wasSyncing = true } else if (wasSyncing) { Timber.d("FileWatchService: sync finished for pair $pairId — 60s settle cooldown") syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 wasSyncing = false } } } } private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { if (!dir.isDirectory) return val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or FileObserver.MOVED_FROM or FileObserver.MOVED_TO val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { object : FileObserver(dir, mask) { override fun onEvent(event: Int, path: String?) { if (event and FileObserver.CREATE != 0 && path != null) { val created = File(dir, path) if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly) } onChangeDetected(pairId, wifiOnly, chargingOnly) } } } else { @Suppress("DEPRECATION") object : FileObserver(dir.absolutePath, mask) { override fun onEvent(event: Int, path: String?) { if (event and FileObserver.CREATE != 0 && path != null) { val created = File(dir, path) if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly) } onChangeDetected(pairId, wifiOnly, chargingOnly) } } } observer.startWatching() fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer) // Recursively watch existing subdirectories dir.listFiles()?.filter { it.isDirectory }?.forEach { sub -> watchDirRecursive(sub, pairId, wifiOnly, chargingOnly) } } private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) { val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath } if (known.isEmpty()) return // Never synced — first sync will be triggered manually val pairEntity = syncPairDao.getById(pairId) ?: return val pair = pairEntity.toDomain() // Use the same accessor + filters as SyncEngine so hidden/excluded/size-filtered files // don't appear as "new" in the catchup scan and trigger a perpetual sync loop. val accessor = if (pair.localPath.startsWith("content://")) LocalAccessor.Saf(Uri.parse(pair.localPath), contentResolver) else LocalAccessor.JavaFile(dir) val current = accessor.walkFiles(pair) val hasNew = current.any { (rel, _) -> rel !in known } val hasModified = current.any { (rel, info) -> val s = known[rel]; s != null && s.localModifiedAt != null && s.localModifiedAt.epochSecond != info.lastModifiedMs / 1000 } val hasDeleted = known.keys.any { rel -> rel !in current } if (hasNew || hasModified || hasDeleted) { Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync") // Cancel any debounce that started before our startup cooldown was set debounceJobs[pairId]?.cancel() debounceJobs.remove(pairId) // Hold cooldown for duration of sync + 60s settle syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000 val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly) WorkManager.getInstance(applicationContext) .enqueueUniqueWork("catchup_$pairId", ExistingWorkPolicy.KEEP, req) scope.launch { try { WorkManager.getInstance(applicationContext) .getWorkInfoByIdFlow(req.id) .first { it?.state?.isFinished == true } syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 } catch (e: CancellationException) { throw e } catch (_: Exception) { syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 } } } } private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { // Ignore events fired by our own sync writing files — prevents the feedback loop // where downloaded/uploaded files trigger another sync indefinitely. if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) { Timber.d("FileWatchService: suppressing change event for pair $pairId (sync cooldown)") return } debounceJobs[pairId]?.cancel() debounceJobs[pairId] = scope.launch { delay(5_000) // Re-check: catchupScan or another path may have already set a cooldown // and handled this sync while we were waiting. if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) { Timber.d("FileWatchService: debounce fired but cooldown active for pair $pairId, skipping") return@launch } val pair = syncPairDao.getById(pairId) if (pair == null || !pair.isEnabled) return@launch Timber.d("FileWatchService: triggering sync for pair $pairId after debounce") // Block new triggers from this point until 60s after sync completes syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000 val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true) WorkManager.getInstance(applicationContext) .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) updateNotificationDynamic("Syncing: ${pair.name}…") scope.launch { try { val info = WorkManager.getInstance(applicationContext) .getWorkInfoByIdFlow(req.id) .first { it?.state?.isFinished == true } // Extend cooldown: 60s after sync finishes to let filesystem settle syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY) val watchCount = fileObservers.keys.size + contentObservers.size val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}" if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) { updateNotificationDynamic("${pair.name}: $summary — $watching") } else { updateNotificationDynamic("$watching") } delay(12_000) updateNotificationDynamic(null) } catch (e: CancellationException) { throw e } catch (_: Exception) { syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000 updateNotificationDynamic(null) } } } } private fun clearWatchers() { fileObservers.values.flatten().forEach { it.stopWatching() } fileObservers.clear() contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) } contentObservers.clear() debounceJobs.values.forEach { it.cancel() } debounceJobs.clear() syncMonitorJobs.values.forEach { it.cancel() } syncMonitorJobs.clear() syncCooldownUntil.clear() } private fun ensureChannel() { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (nm.getNotificationChannel(CHANNEL_WATCH) == null) { nm.createNotificationChannel( NotificationChannel(CHANNEL_WATCH, "File watching", NotificationManager.IMPORTANCE_LOW).apply { description = "Background service watching folders for changes" setShowBadge(false) } ) } } private fun buildNotification(count: Int, overrideText: String? = null): Notification { val tapIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this, CHANNEL_WATCH) .setContentTitle("SyncFlow") .setContentText( overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes" else "Starting file watcher…" ) .setSmallIcon(R.drawable.ic_sync) .setContentIntent(tapIntent) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_MIN) .build() } private fun updateNotification(count: Int) { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIFICATION_ID, buildNotification(count)) } private fun updateNotificationDynamic(overrideText: String?) { val count = fileObservers.keys.size + contentObservers.size val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText)) } }