Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s
Build APK / build (push) Successful in 7m33s
- New native sync engine (SyncEngine/SyncWorker/ConflictResolveWorker,
WorkManager-based) that mirrors selected Nextcloud folders or files to
app-private local storage, periodically and on-demand ("Sync now"),
with never-auto-resolved conflict notifications.
- Single files (not just folders) can now be marked "Sync to device".
- Live sync status (syncing/synced/conflicts) pushes from native to Dart
over a new EventChannel; the persistent header chip/panel now reflects
device-sync status instead of the WebDAV-refresh loading state, with a
cloud_off/cloud_sync/cloud_done/cloud_alert icon set and an expandable
conflicts list with in-app "Keep local"/"Use server" resolution.
Per-item cloud_done/sync badges show on Files tiles.
- Share/Download short-circuit to the local copy for already-synced
files instead of a fresh network fetch.
- Settings gained a Device Sync section (per-folder list, "Sync
everything", Wi-Fi-only toggle, manual "Sync now").
- Fixed two release-only bugs found via on-device testing: Android's
HttpURLConnection silently rejects the PROPFIND method (switched to
OkHttp), and R8 was stripping WorkManager's reflection-instantiated
internals (broadened proguard-rules.pro to keep androidx.work.**
wholesale rather than chasing individual classes).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,10 +54,10 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
|
||||
- UI settings that persist across launches — split into **global** (theme
|
||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||
actions, login lock — see below) and **per-account** (grid/list view,
|
||||
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||
per-folder sort map, cache policy) — see `server.md` for exactly which is
|
||||
which and why
|
||||
actions, login lock, sync-on-cellular — see below) and **per-account**
|
||||
(grid/list view, favorites-only, show-hidden, storage scope, Photos sort,
|
||||
Files' per-folder sort map, cache policy, synced folders) — see
|
||||
`server.md` for exactly which is which and why
|
||||
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
|
||||
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
|
||||
`AppLockService` (a thin wrapper over `local_auth` — this app never
|
||||
|
||||
+151
-6
@@ -157,12 +157,12 @@ rather than needing a rewrite for multi-account support.
|
||||
(`views/move_copy_destination_picker.dart`) is covered in
|
||||
`architecture.md`, including why it can't reuse the Files tab's shared
|
||||
navigation state the way `ShareUploadView` does.
|
||||
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath,
|
||||
{onProgress})`) stream the local file via `Dio().put()` with an explicit
|
||||
`Content-Length` and `onSendProgress`, mirroring the download path. The
|
||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||
- **Uploads**: there's no more in-app-only upload path - `NextcloudService`/
|
||||
`ServerProvider.uploadFileFromPath` were removed once the Files tab's "+"
|
||||
→ "Upload File" was unified with the share-to-upload flow (below). Every
|
||||
upload, however it's triggered, now goes through `ShareUploadView`
|
||||
(caller-chosen destination via `navigateToAbsoluteFolder`, then
|
||||
`UploadService`/`ShareUploadService.kt`).
|
||||
- **Receiving a shared file from another app**: hand-rolled in
|
||||
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
|
||||
`android:launchMode` `singleTask` in the manifest so a second share while
|
||||
@@ -246,6 +246,151 @@ Google Drive/Instagram's "choose a file" flow), the reverse direction of
|
||||
backing out (system back while picking, or a picked-item mismatch) with
|
||||
`RESULT_CANCELED` instead.
|
||||
|
||||
## Device sync
|
||||
|
||||
Mirrors selected folders to app-private local storage
|
||||
(`getExternalFilesDir(null)/sync/<accountId>/...` - wiped on uninstall, no
|
||||
extra storage permission needed) and keeps them updated in the background,
|
||||
even with the app fully closed. Same rationale as the upload/download
|
||||
services for going native instead of a Dart background-task plugin (see
|
||||
above): a periodic job has to run without the Flutter engine loaded, and a
|
||||
notification action has to resolve without launching the UI. Rather than
|
||||
add `workmanager` (whose Dart `callbackDispatcher` spins up a second,
|
||||
minimal Flutter engine that has to re-register every plugin it touches),
|
||||
the whole engine is plain Kotlin using Android's WorkManager directly.
|
||||
|
||||
- [`SyncEngine.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt)
|
||||
holds the shared primitives, reused by both workers below: a Depth-1 (or
|
||||
Depth-0, for refreshing one item) PROPFIND (`propfindChildren`/
|
||||
`propfindSelf`) requesting `d:getetag` alongside the usual props - unlike
|
||||
every PROPFIND in `nextcloud_service.dart`, which never requests it
|
||||
(`etag` is a dead field on `NextcloudItem` today); plain
|
||||
`HttpURLConnection` GET/PUT (`downloadFile`/`uploadFile`, same style as
|
||||
`DownloadService.kt`/`ShareUploadService.kt`); and `diffFolder`, which
|
||||
compares one folder's freshly-walked manifest against the persisted
|
||||
per-account sync-state map (native `SharedPreferences`, JSON keyed by
|
||||
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
|
||||
file: download (new, or server `etag` changed), upload (local file's
|
||||
mtime/size changed and the server didn't), delete locally (missing
|
||||
server-side, unchanged locally), respect a local deletion (file's gone
|
||||
and the server didn't change either - don't recreate it), or flag a
|
||||
**conflict** (both changed since the last recorded state).
|
||||
- [`SyncWorker.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt)
|
||||
(`CoroutineWorker`) is both the periodic job and the one-off "Sync now":
|
||||
for each configured path, `propfindSelf`s it first to check whether it's
|
||||
a file or a folder - a folder gets the full recursive
|
||||
`walkRemoteTree`, a file is diffed directly as a one-item list (nothing
|
||||
else about `diffFolder`/download/upload/delete cares whether its entries
|
||||
came from a walk or a single lookup, so single-file sync needed no engine
|
||||
changes, just this one branch) - applies `diffFolder`'s decisions, then
|
||||
posts a summary notification (files updated/uploaded/removed) and, for
|
||||
any conflicts, one notification per file with two actions.
|
||||
- **Live status reaches Dart via a push channel, not polling** -
|
||||
[`SyncStatusBus.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt)
|
||||
is a plain in-process pub/sub (no IPC needed - the workers and
|
||||
`MainActivity` share one process) that `SyncWorker`/`ConflictResolveWorker`
|
||||
publish into (syncing started/stopped, which `fileId`s are mid-transfer
|
||||
right now, new/resolved conflicts) and `MainActivity.kt`'s
|
||||
`dev.ayushya.noo/sync_service/status` `EventChannel` forwards to Dart,
|
||||
same pattern as the share/pick-intent channels. It deliberately does
|
||||
*not* track "which files are already synced" itself - that's read fresh
|
||||
from `SyncEngine`'s durable per-account state map
|
||||
(`SyncEngine.loadState(accountId).keys`) each time a snapshot is built,
|
||||
so there's one source of truth for "synced" instead of two that could
|
||||
drift. `SyncService.getStatus()` (one-shot, seeds `ServerProvider` right
|
||||
after login/account-switch) and `SyncService.statusStream` (live) both
|
||||
return the same snapshot shape. `ServerProvider.syncHeaderStatus`
|
||||
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
|
||||
chip/panel, replacing what used to be the WebDAV-refresh-loading
|
||||
indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict
|
||||
- drives the small corner badge on Files' tiles, `SyncStatusBadge`) are
|
||||
both computed from this state, not fetched per-item.
|
||||
- **In-app conflict resolution reuses the exact same enqueue path as the
|
||||
notification actions** - `ConflictResolveWorker.enqueue(...)` is a
|
||||
shared companion function; `SyncConflictReceiver` (the notification
|
||||
action) and `MainActivity.kt`'s `resolveConflict` MethodChannel method
|
||||
(the sync header's "Keep local"/"Use server" buttons,
|
||||
`ServerProvider.resolveSyncConflict`) both just call it, so there's one
|
||||
resolution code path regardless of which surface triggered it.
|
||||
- **Conflicts are never auto-resolved.** The notification's "Keep local"/
|
||||
"Use server" actions are `PendingIntent.getBroadcast`s (same shape as the
|
||||
Cancel action on upload/download notifications, just broadcast instead of
|
||||
service-targeted) to
|
||||
[`SyncConflictReceiver`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt)
|
||||
- a manifest-registered `BroadcastReceiver` (works even with the app
|
||||
process dead) that can't itself block on network, so it just dismisses
|
||||
the notification and enqueues a one-shot
|
||||
[`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt)
|
||||
to actually push the local copy up or pull the server copy down and
|
||||
refresh that file's recorded state.
|
||||
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
|
||||
(`reschedule`/`cancel`/`syncNow`) is the only bridge from Dart: a periodic
|
||||
`WorkRequest`'s input `Data` and `Constraints` are fixed at enqueue time,
|
||||
so changing the synced-folder list, the active account, or the Wi-Fi-only
|
||||
setting means cancelling and re-enqueueing, not updating in place.
|
||||
[`SyncService`](../../lib/services/sync_service.dart) (Dart) wraps this -
|
||||
`ServerProvider` calls `reschedule` after every successful login/account
|
||||
switch and every synced-folder/`syncOnCellular` change, and `cancel` on
|
||||
logout/last-account-removed. The network constraint is
|
||||
`NetworkType.UNMETERED` by default (`!syncOnCellular`, Wi-Fi only) or
|
||||
`NetworkType.CONNECTED` if the user's opted into cellular sync.
|
||||
- Synced-path list (`ServerProvider.syncedPaths` - files or folders, not
|
||||
just folders despite the name of the underlying pref/native `Data` key,
|
||||
which stayed `ui_synced_folders`/`folders` to avoid a storage-key
|
||||
migration for a rename) follows the standard per-account-pref pattern
|
||||
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); so does
|
||||
`syncEverything` (`ui_sync_everything`, per account) - when on,
|
||||
`SyncService` sends `['/']` as the path list instead of `syncedPaths`,
|
||||
mirroring the whole account rather than requiring per-item opt-in.
|
||||
`syncOnCellular` is a plain global pref. All three are managed from
|
||||
Settings → Device Sync (a "Sync everything" switch, the path list with
|
||||
remove buttons - hidden while "Sync everything" is on - the cellular
|
||||
toggle, and a manual "Sync now"); individual files or folders are
|
||||
additionally toggled from Files' selection toolbar ("Sync to device",
|
||||
single-selection, either item type).
|
||||
- **`android/app/proguard-rules.pro` exists specifically for this feature,
|
||||
and keeps `androidx.work.**` wholesale rather than naming individual
|
||||
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
|
||||
release builds (`FlutterPlugin.kt` sets `isMinifyEnabled = true`
|
||||
unconditionally for the `release` build type, and auto-wires this exact
|
||||
file if it exists - nothing in this project's own `build.gradle.kts`
|
||||
opts into it), which broke device sync on real-device testing **twice**
|
||||
in a row: first `WorkDatabase` (WorkManager locates its bundled Room
|
||||
database by reflecting off the abstract database class's own,
|
||||
possibly-renamed, name), then - after narrowly keeping just that class -
|
||||
`OverwritingInputMerger` (WorkManager's default input merger, also
|
||||
reflection-instantiated) broke the exact same way and silently ate every
|
||||
`enqueueUniqueWork` call, including "Sync now", with zero indication
|
||||
beyond a `WM-InputMerger` `NoSuchMethodException` in logcat - no crash,
|
||||
no Dart-visible error, just a folder that stayed empty. R8's member-level
|
||||
shrinking strips whatever a class's *reflection-only* callers don't
|
||||
reference directly, even when the class itself survives a plain `-keep
|
||||
class` with no wildcard, and WorkManager reflects into more of its own
|
||||
internals than any one test pass is likely to exercise - hence the
|
||||
wholesale keep instead of chasing individual classes one crash at a
|
||||
time. If adding another native background component reached only via
|
||||
reflection (not a manifest-declared component, which AGP already keeps
|
||||
automatically), don't assume default AndroidX consumer rules cover it -
|
||||
verify on an actual release build, not just `flutter analyze`/a debug
|
||||
build, since minification only applies to release.
|
||||
- **Files land under `Android/data/<package>/files/sync/...`
|
||||
(`getExternalFilesDir`), which no third-party file manager can browse
|
||||
without root** - Android's scoped storage sandboxes that whole directory
|
||||
tree from other apps by design, same as any app-private storage. This
|
||||
surprised real-device testing (a file manager app logged "Can't read
|
||||
directory ... trying su" and came up empty even though the sync had
|
||||
actually worked) - it's expected, not a bug. Confirm synced files
|
||||
landed via `adb shell run-as`/a rooted shell, not a regular file
|
||||
manager UI.
|
||||
- **Already-synced files skip the network** in two places:
|
||||
`ServerProvider.localSyncedFilePath(item)` is a pure function of the
|
||||
remote path (mirrors `SyncEngine.kt`'s `syncRoot` layout exactly, so Dart
|
||||
never needs to read the native sync-state `SharedPreferences`) that
|
||||
returns the local mirror path if it exists on disk. `ShareSheet`'s "Share
|
||||
file directly" and `FilesView._downloadSelected` (only when *every*
|
||||
selected file is already synced - a mixed selection still goes through
|
||||
the normal `DownloadService` batch) both check it first.
|
||||
|
||||
## Multi-account storage & session persistence
|
||||
|
||||
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||
|
||||
@@ -63,7 +63,17 @@ widgets. Key points:
|
||||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
||||
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
||||
the classic Material refresh spinner live.
|
||||
the classic Material refresh spinner live. Its persistent chip/panel
|
||||
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
|
||||
status (`ServerProvider.syncHeaderStatus`), not the WebDAV-refresh
|
||||
loading state the pull gesture itself triggers - that has its own,
|
||||
separate floating spinner bubble, so nothing was lost by handing the
|
||||
persistent text/icon over.
|
||||
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
|
||||
corner badge over a thumbnail showing per-item device-sync status
|
||||
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
|
||||
list and grid tiles today. Reuse this rather than a new ad hoc badge if
|
||||
another view starts showing sync status per item.
|
||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||
for the video player's seek bar, plus a perpetually-animated
|
||||
|
||||
Reference in New Issue
Block a user