v1.0.15: ON_CHANGE file watching, browser fix, rich notifications

- Add FileWatchService for real-time ON_CHANGE sync (FileObserver for
  direct paths, ContentObserver for SAF content:// URIs), 5s debounce
- Fix remote browser stuck spinner: cancel in-flight jobs on navigation,
  reset entries immediately, add Retry button on error
- Fix browser reuse bug: LaunchedEffect key now includes initialPath
- Fix WebDavProvider: rethrow XML parse errors (no more silent Empty
  folder) and URL-decode file names from href
- Notifications now use BigTextStyle showing per-file-type counts
  (Uploaded/Downloaded/Deleted) matching Autosync notification style
- Wire FileWatchService into BootReceiver and HomeViewModel toggle
- Register FileWatchService in AndroidManifest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 02:55:19 +00:00
parent e22db9bced
commit f751b26a9e
10 changed files with 318 additions and 94 deletions
+6
View File
@@ -68,6 +68,12 @@
</intent-filter>
</receiver>
<!-- File watcher for ON_CHANGE sync pairs -->
<service
android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -158,43 +158,42 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>()
try {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(xml.reader())
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(xml.reader())
var href = ""; var isCollection = false; var contentLength = 0L
var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null
var inResponse = false; var inProp = false
var href = ""; var isCollection = false; var contentLength = 0L
var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null
var inResponse = false; var inProp = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
val tag = parser.name?.substringAfterLast(':')?.lowercase()
when (eventType) {
XmlPullParser.START_TAG -> when (tag) {
"response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null }
"prop" -> inProp = true
"href" -> if (!inProp) href = parser.nextText().trim()
"collection" -> if (inProp) isCollection = true
"getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L
"getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim())
"getetag" -> if (inProp) etag = parser.nextText().trim().trim('"')
"getcontenttype" -> if (inProp) contentType = parser.nextText().trim()
}
XmlPullParser.END_TAG -> when (tag) {
"prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) {
val name = href.trimEnd('/').substringAfterLast('/')
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
val tag = parser.name?.substringAfterLast(':')?.lowercase()
when (eventType) {
XmlPullParser.START_TAG -> when (tag) {
"response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null }
"prop" -> inProp = true
"href" -> if (!inProp) href = parser.nextText().trim()
"collection" -> if (inProp) isCollection = true
"getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L
"getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim())
"getetag" -> if (inProp) etag = parser.nextText().trim().trim('"')
"getcontenttype" -> if (inProp) contentType = parser.nextText().trim()
}
XmlPullParser.END_TAG -> when (tag) {
"prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) {
val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
}
eventType = parser.next()
}
} catch (_: Exception) {}
eventType = parser.next()
}
return if (dropFirst) results.drop(1) else results
}
@@ -27,7 +27,7 @@ fun RemoteBrowserDialog(
onDismiss: () -> Unit,
vm: RemoteBrowserViewModel = hiltViewModel(),
) {
LaunchedEffect(accountId) { vm.init(accountId, initialPath) }
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState()
@@ -81,6 +81,7 @@ fun RemoteBrowserDialog(
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = MaterialTheme.colorScheme.error)
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
}
}
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -6,6 +6,7 @@ import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.model.RemoteFile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -30,15 +31,19 @@ class RemoteBrowserViewModel @Inject constructor(
private val _state = MutableStateFlow(BrowserState())
val state = _state.asStateFlow()
private var loadJob: Job? = null
fun init(accountId: Long, startPath: String = "/") {
_state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) }
loadPath(accountId, startPath)
loadJob?.cancel()
_state.value = BrowserState(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath), isLoading = true)
loadJob = loadPath(accountId, startPath)
}
fun navigateTo(path: String) {
val accountId = _state.value.accountId
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) }
loadPath(accountId, path)
loadJob?.cancel()
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(accountId, path)
}
fun navigateUp(): Boolean {
@@ -46,30 +51,36 @@ class RemoteBrowserViewModel @Inject constructor(
if (stack.size <= 1) return false
val newStack = stack.dropLast(1)
val parent = newStack.last()
_state.update { it.copy(currentPath = parent, pathStack = newStack) }
loadPath(_state.value.accountId, parent)
loadJob?.cancel()
_state.update { it.copy(currentPath = parent, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, parent)
return true
}
private fun loadPath(accountId: Long, path: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val account = accountRepository.getAccount(accountId)
if (account == null) {
_state.update { it.copy(isLoading = false, error = "Account not found") }
return@launch
}
val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
return@launch
}
provider.listFiles(path)
.onSuccess { files ->
_state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
}
fun retry() {
val s = _state.value
if (s.accountId == -1L) return
loadJob?.cancel()
_state.update { it.copy(isLoading = true, error = null) }
loadJob = loadPath(s.accountId, s.currentPath)
}
private fun loadPath(accountId: Long, path: String): Job = viewModelScope.launch {
val account = accountRepository.getAccount(accountId)
if (account == null) {
_state.update { it.copy(isLoading = false, error = "Account not found") }
return@launch
}
val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to create provider") }
return@launch
}
provider.listFiles(path)
.onSuccess { files ->
_state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
}
}
}
@@ -1,13 +1,17 @@
package com.syncflow.ui.home
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -17,6 +21,7 @@ import javax.inject.Inject
class HomeViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val workManager: WorkManager,
@ApplicationContext private val context: Context,
) : ViewModel() {
val syncPairs = syncPairDao.observeAll()
@@ -29,21 +34,28 @@ class HomeViewModel @Inject constructor(
fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch {
syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled))
if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) {
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
workManager.enqueueUniquePeriodicWork(
"periodic_${pair.id}",
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
req,
)
val nowEnabled = !pair.isEnabled
syncPairDao.update(pair.copy(isEnabled = nowEnabled))
if (nowEnabled) {
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> FileWatchService.start(context)
ScheduleType.MANUAL -> { /* nothing */ }
else -> {
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
workManager.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
} else {
workManager.cancelAllWorkByTag("sync_${pair.id}")
// Refresh watcher (it will stop itself if no ON_CHANGE pairs remain)
if (pair.scheduleType == ScheduleType.ON_CHANGE) {
FileWatchService.start(context)
}
}
}
}
@@ -3,6 +3,7 @@ package com.syncflow.worker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -22,17 +23,24 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
syncPairDao.getEnabled()
.filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE }
.forEach { pair ->
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req)
val pairs = syncPairDao.getEnabled()
var hasOnChange = false
pairs.forEach { pair ->
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> hasOnChange = true
ScheduleType.MANUAL -> { /* nothing */ }
else -> {
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
if (hasOnChange) FileWatchService.start(context)
} finally {
pending.finish()
}
@@ -0,0 +1,182 @@
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 androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@AndroidEntryPoint
class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
private val fileObservers = mutableMapOf<Long, FileObserver>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
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() {
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://")) {
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 for pair $pairId")
} else {
val dir = File(localPath)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $localPath")
return@forEach
}
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?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
} else {
@Suppress("DEPRECATION")
object : FileObserver(localPath, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
}
observer.startWatching()
fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
}
}
val count = fileObservers.size + contentObservers.size
updateNotification(count)
if (count == 0) {
Timber.d("FileWatchService: no ON_CHANGE pairs, stopping")
stopSelf()
}
}
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
delay(5_000)
val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
}
}
private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() }
fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.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_MIN).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
}
)
}
}
private fun buildNotification(count: Int): 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(
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))
}
}
@@ -56,17 +56,19 @@ class SyncWorker @AssistedInject constructor(
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val summary = buildString {
if (result.uploaded > 0) append("${result.uploaded} ")
if (result.downloaded > 0) append("${result.downloaded} ")
if (result.deleted > 0) append("🗑${result.deleted} ")
if (result.conflicts > 0) append("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}.trim().ifEmpty { "Up to date" }
val lines = buildList {
if (result.uploaded > 0) add(" Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add(" Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add(" ${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n")
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name}Synced",
text = summary,
title = "${pair.name}Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
@@ -122,21 +124,24 @@ class SyncWorker @AssistedInject constructor(
ForegroundInfo(NOTIFICATION_ID, notification)
}
private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int) {
private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int, bigText: String? = null) {
val tapIntent = PendingIntent.getActivity(
applicationContext, id,
Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(id, NotificationCompat.Builder(applicationContext, channelId)
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_sync)
.setPriority(priority)
.setContentIntent(tapIntent)
.setAutoCancel(true)
.build())
if (bigText != null) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
}
nm.notify(id, builder.build())
}
companion object {
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.14
VERSION_CODE=15
VERSION_NAME=1.0.15
VERSION_CODE=16