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,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") }
}
}
}