Fix device sync: folder status badges, remove-deletes-local, multi-select, header summary
Build APK / build (push) Successful in 5m35s

- Folders now show the synced status badge themselves, not just their
  nested files - device-sync state never tracked folders individually, so
  syncStatusFor derives a folder's badge from whether its path falls
  within sync scope instead.
- Turning sync off for a path now deletes its local mirror and clears its
  native sync-state entries (SyncEngine.removeLocalSync), instead of just
  stopping future updates and leaving the downloaded copy behind.
- "Sync to device" in Files' selection toolbar now works over the whole
  selection at once instead of being gated to a single selected item.
- The sync header's expanded panel shows real folder/item counts instead
  of a static "Synced" label that stayed accurate-sounding even before
  anything had actually synced.
- Confirmation SnackBars on starting/stopping sync from both the
  selection toolbar and Settings' Device Sync card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 17:20:14 -04:00
co-authored by Claude Sonnet 5
parent ff5f04e9a1
commit 49ae712f5e
9 changed files with 273 additions and 51 deletions
@@ -136,6 +136,7 @@ class MainActivity : FlutterFragmentActivity() {
"syncNow" -> syncNow(call, result)
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
"resolveConflict" -> resolveConflict(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented()
}
}
@@ -405,6 +406,24 @@ class MainActivity : FlutterFragmentActivity() {
result.success(null)
}
/// Deletes a path's local mirror once the user turns sync off for it -
/// see [SyncEngine.removeLocalSync]. Runs off the main thread since it
/// walks/deletes a directory tree; `result.success` is posted back via
/// [mainHandler] since MethodChannel results must be delivered on the
/// platform thread.
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val path = call.argument<String>("path")
if (accountId == null || path == null) {
result.error("bad_args", "Missing required arguments", null)
return
}
Thread {
SyncEngine.removeLocalSync(applicationContext, accountId, path)
mainHandler.post { result.success(null) }
}.start()
}
/// One-off immediate run (Settings' "Sync now"), independent of the
/// periodic schedule.
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
@@ -356,6 +356,35 @@ object SyncEngine {
return actions
}
/**
* Deletes [path]'s local mirror (a single file, or a whole folder's
* worth of files) and removes its entries from the persisted sync-state
* map, called when the user turns sync off for that path - without
* clearing the state too, a later re-add would see the (now missing)
* local file as "deleted, server unchanged" and skip re-downloading it
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
* of pulling it back down.
*/
fun removeLocalSync(context: Context, accountId: String, path: String) {
val root = syncRoot(context, accountId)
val state = loadState(context, accountId).toMutableMap()
var cleanPath = path.trim()
if (cleanPath.startsWith("/")) cleanPath = cleanPath.substring(1)
cleanPath = cleanPath.trimEnd('/')
val prefix = if (cleanPath.isEmpty()) "" else "$cleanPath/"
val toRemove = state.filterValues { s ->
s.relPath == cleanPath || (prefix.isNotEmpty() && s.relPath.startsWith(prefix))
}.keys
for (fileId in toRemove) state.remove(fileId)
saveState(context, accountId, state)
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
if (target.exists()) target.deleteRecursively()
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
}
fun folderIsSyncedUnder(itemPath: String, syncedFolders: List<String>): String? {
val normalizedItem = itemPath.trimEnd('/')
for (folder in syncedFolders) {